"""The capture loop: miss counting, flagging, and the configuration warnings.

sample_loop() is the subtlest code in the repo -- it decides how many
measurements passed unseen, and getting it wrong slips sample_index against chip
time in a way nothing downstream can detect. It is driven here by a scripted
sensor against a fake clock, so a stall of an exact size can be injected and the
resulting rows checked exactly.
"""

import queue

import pytest

import capture
import logger
import rm3100
from conftest import FakeSensor, grid_events

PERIOD = 0.01           # 100 Hz, chosen so stalls are easy to state in periods


def drain(q):
    """Every row the loop queued, in order."""
    rows = []
    while not q.empty():
        rows.append(q.get_nowait())
    return rows


def run_loop(sensor, fake_clock, duration, dt=PERIOD, maxsize=0):
    """Run sample_loop against a fake sensor. Returns (rows, stats)."""
    fake_clock(sensor)
    q = queue.Queue(maxsize=maxsize)
    stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
             "truncated": False}
    logger.sample_loop(sensor, q, duration, dt, stats)
    return drain(q), stats


def indices(rows):
    return [r[0] for r in rows]


def flags(rows):
    return [r[5] for r in rows]


# --------------------------------------------------------------------------
# The clean case
# --------------------------------------------------------------------------

def test_a_clean_run_loses_nothing(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 60))
    rows, stats = run_loop(sensor, fake_clock, duration=0.5)

    assert stats["missed"] == 0
    assert stats["ambiguous"] == 0
    assert all(f == "" for f in flags(rows))
    assert len(rows) > 40


def test_the_sample_index_is_contiguous_from_zero(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 60))
    rows, _ = run_loop(sensor, fake_clock, duration=0.5)
    assert indices(rows) == list(range(len(rows)))


def test_every_measurement_is_read_exactly_once(fake_clock):
    """No duplicates: DRC1 clears DRDY on the results read, so a re-read cannot
    return the same measurement -- and the loop must not ask for one."""
    sensor = FakeSensor(grid_events(PERIOD, 60))
    rows, _ = run_loop(sensor, fake_clock, duration=0.5)
    x_values = [r[2] for r in rows]
    assert len(set(x_values)) == len(x_values)
    assert x_values == sorted(x_values)          # grid_events counts upward


def test_the_timestamp_is_the_bracket_midpoint(fake_clock):
    """Not the read time: the measurement completed somewhere in the bracket."""
    sensor = FakeSensor(grid_events(PERIOD, 40))
    rows, _ = run_loop(sensor, fake_clock, duration=0.3)
    times = [r[1] for r in rows]
    gaps = [b - a for a, b in zip(times, times[1:])]
    # Resolved only to the poll interval, which is what a real bracket gives too.
    assert all(g == pytest.approx(PERIOD, abs=1e-3) for g in gaps)


def test_the_worst_bracket_is_recorded(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 40))
    _, stats = run_loop(sensor, fake_clock, duration=0.3)
    # No stall, so the bracket is one poll interval and nowhere near a period.
    assert 0 < stats["max_bracket"] < PERIOD / 2


def test_a_stale_drdy_at_start_is_discarded(fake_clock):
    """BIST leaves DRDY high over zeroed registers; sample 0 must be fresh."""
    sensor = FakeSensor(grid_events(PERIOD, 40, start=0.0, counts=(0, 0, 0)))
    sensor.pending = (0, 0, 0)          # as if left set by whatever ran before
    rows, _ = run_loop(sensor, fake_clock, duration=0.2)
    assert rows
    assert rows[0][2:5] != (0, 0, 0) or sensor.reads[0] == (0, 0, 0)
    # The discard happened: one more read than rows written.
    assert len(sensor.reads) == len(rows) + 1


def test_duration_zero_keeps_running(fake_clock):
    """0 means 'until Ctrl-C', so the deadline must be infinite, not immediate.

    Nothing inside the loop ends it, so the sensor is what stops the test --
    which is also the proof that the loop was still going.
    """
    sensor = FakeSensor(grid_events(PERIOD, 200), max_polls=2000)
    fake_clock(sensor)
    q = queue.Queue()
    stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
             "truncated": False}
    with pytest.raises(FakeSensor.Exhausted):
        logger.sample_loop(sensor, q, 0.0, PERIOD, stats)
    # It kept sampling until the sensor gave out, rather than returning at once.
    assert q.qsize() >= 50


# --------------------------------------------------------------------------
# Losing measurements
# --------------------------------------------------------------------------

def test_a_two_period_stall_records_one_placeholder(fake_clock):
    """Two completions inside one bracket: one was read, one was lost."""
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.1 * PERIOD)])
    rows, stats = run_loop(sensor, fake_clock, duration=0.5)

    assert stats["missed"] == 1
    assert stats["ambiguous"] == 0
    placeholders = [r for r in rows if logger.WARN_MISSED in r[5]]
    assert len(placeholders) == 1
    # A placeholder carries no data and keeps the index contiguous.
    assert placeholders[0][2:5] == (None, None, None)
    assert indices(rows) == list(range(len(rows)))


def test_a_longer_stall_records_every_lost_grid_point(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 80), stalls=[(0.2, 4.1 * PERIOD)])
    rows, stats = run_loop(sensor, fake_clock, duration=0.7)

    assert stats["missed"] == 3
    assert sum(logger.WARN_MISSED in r[5] for r in rows) == 3
    assert indices(rows) == list(range(len(rows)))


def test_placeholders_sit_on_the_grid_not_bunched_at_the_end(fake_clock):
    """They mark where the lost measurements actually completed."""
    sensor = FakeSensor(grid_events(PERIOD, 80), stalls=[(0.2, 4.1 * PERIOD)])
    rows, _ = run_loop(sensor, fake_clock, duration=0.7)
    lost = [r for r in rows if logger.WARN_MISSED in r[5]]
    gaps = [b[1] - a[1] for a, b in zip(lost, lost[1:])]
    assert all(g == pytest.approx(PERIOD, rel=0.05) for g in gaps)


def test_a_late_read_within_one_period_is_not_a_miss(fake_clock):
    """The case that motivated the bracket: a host stall that lost nothing.

    An interval-based estimator would insert a spurious placeholder here and
    slip the index for the rest of the run.
    """
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.1 * PERIOD)])
    rows, stats = run_loop(sensor, fake_clock, duration=0.5)

    assert stats["missed"] == 0
    assert all(logger.WARN_MISSED not in f for f in flags(rows))


def test_the_stall_shows_up_in_the_worst_bracket(fake_clock):
    """Losing nothing is not the same as having had margin."""
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.1 * PERIOD)])
    _, stats = run_loop(sensor, fake_clock, duration=0.5)
    assert stats["max_bracket"] > 0.7 * PERIOD


# --------------------------------------------------------------------------
# Ambiguity
# --------------------------------------------------------------------------

def test_a_gap_near_a_half_period_is_flagged_ambiguous(fake_clock):
    """1.35 periods rounds to one, so no placeholder -- but it is a guess."""
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 1.35 * PERIOD)])
    rows, stats = run_loop(sensor, fake_clock, duration=0.5)

    assert stats["ambiguous"] == 1
    assert stats["missed"] == 0
    flagged = [r for r in rows if logger.WARN_AMBIGUOUS in r[5]]
    assert len(flagged) == 1
    # It rides on the real sample ending the gap, which keeps its data.
    assert flagged[0][2] is not None
    assert logger.WARN_MISSED not in flagged[0][5]


def test_an_ambiguous_gap_that_did_lose_something_flags_both(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.5 * PERIOD)])
    rows, stats = run_loop(sensor, fake_clock, duration=0.5)

    assert stats["ambiguous"] == 1
    assert stats["missed"] == 2
    placeholders = [r for r in rows if logger.WARN_MISSED in r[5]]
    assert placeholders
    assert all(logger.WARN_AMBIGUOUS in r[5] for r in placeholders)


def test_a_gap_landing_squarely_on_a_period_is_not_ambiguous(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 60), stalls=[(0.2, 2.02 * PERIOD)])
    _, stats = run_loop(sensor, fake_clock, duration=0.5)
    assert stats["ambiguous"] == 0
    assert stats["missed"] == 1


def test_the_ambiguity_margin_is_the_documented_quarter_period():
    assert logger.AMBIGUITY_MARGIN == 0.25


# --------------------------------------------------------------------------
# Backpressure
# --------------------------------------------------------------------------

def test_a_full_queue_truncates_rather_than_holing_the_index(fake_clock):
    """A dropped row would break contiguity and make the file unloadable."""
    sensor = FakeSensor(grid_events(PERIOD, 200))
    rows, stats = run_loop(sensor, fake_clock, duration=1.5, maxsize=8)

    assert stats["truncated"] is True
    assert len(rows) == 8
    assert indices(rows) == list(range(8))


def test_an_ample_queue_never_truncates(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 60))
    _, stats = run_loop(sensor, fake_clock, duration=0.5, maxsize=10_000)
    assert stats["truncated"] is False


# --------------------------------------------------------------------------
# calibrate_period
# --------------------------------------------------------------------------

def test_calibrate_period_recovers_the_true_period(fake_clock):
    """The nominal value is 6-9% out on real hardware; the fit has to beat that."""
    sensor = FakeSensor(grid_events(PERIOD, 400))
    fake_clock(sensor)
    dt, host_cost = logger.calibrate_period(sensor, PERIOD * 1.07, seconds=1.0)
    assert dt == pytest.approx(PERIOD, rel=1e-3)
    assert host_cost > 0


def test_calibrate_period_bootstraps_when_the_nominal_is_badly_wrong(fake_clock):
    """When the cycle count governs, the TMRC table value is out by a factor.

    A threshold built on it rejects every interval, so the period is seeded from
    observation instead.
    """
    sensor = FakeSensor(grid_events(PERIOD, 400))
    fake_clock(sensor)
    dt, _ = logger.calibrate_period(sensor, PERIOD / 8, seconds=1.0)
    assert dt == pytest.approx(PERIOD, rel=1e-3)


def test_calibrate_period_reports_a_plausible_host_cost(fake_clock):
    sensor = FakeSensor(grid_events(PERIOD, 400))
    fake_clock(sensor)
    _, host_cost = logger.calibrate_period(sensor, PERIOD, seconds=1.0)
    # One poll plus one read, and nothing else charged to the sample.
    assert host_cost == pytest.approx(
        sensor.poll_cost + sensor.read_cost, rel=0.5)
    assert host_cost < PERIOD


def test_calibrate_period_gives_up_without_enough_clean_samples(fake_clock):
    """Returning None is itself the answer: the rate is not sustainable."""
    sensor = FakeSensor(grid_events(PERIOD, 4))
    fake_clock(sensor)
    assert logger.calibrate_period(sensor, PERIOD, seconds=0.5) == (None, None)


# --------------------------------------------------------------------------
# End to end: sample_loop -> writer_thread -> capture.load
# --------------------------------------------------------------------------

def test_a_logged_run_reloads_through_capture(tmp_path, fake_clock):
    """The two halves of the format contract, checked against each other."""
    sensor = FakeSensor(grid_events(PERIOD, 400), stalls=[(0.5, 2.1 * PERIOD)])
    fake_clock(sensor)
    q = queue.Queue()
    stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
             "truncated": False}
    logger.sample_loop(sensor, q, 2.0, PERIOD, stats)
    q.put(logger._SENTINEL)

    path = tmp_path / "run.csv"
    meta = {
        "rm3100_capture": 1,
        "nominal_rate_hz": 1 / PERIOD,
        "cycle_count": 100,
        "tesla_per_count": repr(rm3100.tesla_per_count(100)),
        "calibrated_period_s": repr(PERIOD),
        "note": "synthetic",
    }
    logger.writer_thread(q, str(path), meta, stats)

    cap = capture.load(str(path))
    assert len(cap.sample_index) == stats["rows"]
    assert cap.missed.sum() == stats["missed"] == 1
    assert cap.meta["note"] == "synthetic"
    assert cap.true_rate_hz == pytest.approx(1 / PERIOD, rel=1e-3)


def test_the_writer_writes_zeros_not_blanks_for_a_placeholder(tmp_path):
    """x/y/z must parse as integers on every row; the flag marks the empty ones."""
    q = queue.Queue()
    q.put((0, 1_700_000_000.0, 10, 20, 30, ""))
    q.put((1, 1_700_000_000.01, None, None, None, logger.WARN_MISSED))
    q.put(logger._SENTINEL)
    stats = {"rows": 0, "missed": 1}

    path = tmp_path / "w.csv"
    logger.writer_thread(q, str(path), {
        "rm3100_capture": 1,
        "tesla_per_count": repr(rm3100.tesla_per_count(100))}, stats)

    lines = path.read_text().splitlines()
    assert lines[-1] == f"1,1700000000.010000,0,0,0,{logger.WARN_MISSED}"
    assert stats["rows"] == 2


def test_the_header_is_one_line_per_key(tmp_path):
    handle_path = tmp_path / "h.csv"
    with open(handle_path, "w") as handle:
        logger.write_header(handle, {"a": 1, "b": "two"})
    assert handle_path.read_text() == "# a: 1\n# b: two\n"


# --------------------------------------------------------------------------
# Configuration warnings -- each one guards against a silent capture defect
# --------------------------------------------------------------------------

def warnings_for(capsys, cfg, bus_speed=750, requested_rate=None):
    logger.print_plan(cfg, bus_speed, requested_rate)
    return capsys.readouterr().err


def test_the_default_configuration_warns_about_nothing(capsys):
    assert warnings_for(capsys, rm3100.plan()) == ""


def test_a_thin_dither_margin_is_warned_about(capsys):
    err = warnings_for(capsys, rm3100.plan(cycle_count=40))
    assert "below the recommended" in err
    assert "characterize.py" in err          # and says how to settle it


def test_an_idling_sensor_is_warned_about(capsys):
    err = warnings_for(capsys, rm3100.plan(cycle_count=50, tmrc=0x99))
    assert "idle" in err


def test_a_rate_that_could_not_be_met_is_warned_about(capsys):
    err = warnings_for(capsys, rm3100.plan(rate_hz=5000), requested_rate=5000)
    assert "was requested but this configuration runs" in err


def test_sampling_below_twice_mains_is_warned_about(capsys):
    """The one error post-processing cannot undo."""
    err = warnings_for(capsys, rm3100.plan(rate_hz=32), requested_rate=32)
    assert "Nyquist" in err and "mains" in err
    assert "4.0" in err                      # 60 Hz folds to ~4 Hz at 32 Hz


def test_an_extrapolated_cycle_count_is_warned_about(capsys):
    err = warnings_for(capsys, rm3100.plan(cycle_count=1000))
    assert "extrapolated" in err
    assert str(rm3100.MAX_SPEC_CYCLE_COUNT) in err


def test_a_bus_that_cannot_keep_up_is_warned_about_with_a_fix(capsys):
    err = warnings_for(capsys, rm3100.plan(), bus_speed=20)
    assert "of every period" in err
    assert "kHz would fit" in err


def test_the_plan_derivation_is_printed_for_checking(capsys):
    logger.print_plan(rm3100.plan(), 750, None)
    out = capsys.readouterr().out
    for field in ("cycle count", "sensor ceiling", "TMRC", "predicted rate",
                  "resolution", "duty", "bus 750 kHz"):
        assert field in out


def test_the_requested_rate_is_echoed_when_given(capsys):
    logger.print_plan(rm3100.plan(rate_hz=50), 750, 50)
    assert "requested" in capsys.readouterr().out


# --------------------------------------------------------------------------
# Small helpers
# --------------------------------------------------------------------------

def test_set_thread_nice_reports_what_took_effect():
    """Lowering own priority needs no privilege, so this must work anywhere."""
    got = logger.set_thread_nice(logger.WRITER_NICE)
    assert got in (logger.WRITER_NICE, None)


def test_raising_priority_without_privilege_returns_none():
    if logger.set_thread_nice(0) is None:
        pytest.skip("thread priority is unavailable on this platform")
    import os
    if os.geteuid() == 0:
        pytest.skip("running as root, where the raise succeeds")
    assert logger.set_thread_nice(logger.SAMPLER_NICE) is None


def test_restore_ownership_is_a_no_op_for_a_normal_user(tmp_path):
    path = tmp_path / "f.csv"
    path.write_text("x")
    logger.restore_ownership(str(path))      # must not raise
    assert path.read_text() == "x"
