"""Write synthetic captures, so tests never need hardware or a recorded file.

Shared by test_capture.py, which uses it to break one guarantee at a time, and
test_compare.py, which uses it to plant a known answer -- a scale factor, a
rotation, a tone at a chosen fraction of the sample rate -- and check that the
analysis recovers it.

Defaults produce a clean, loadable capture; every argument exists to change one
thing about it.
"""

import numpy as np

import capture
import rm3100

CYCLE_COUNT = 100
DT = 1.0 / 250.0          # a deliberately non-nominal true period


def write_capture(path, rows=200, dt=DT, nominal_hz=282.0, header=None,
                  flags=None, drop_header_key=None, extra_lines=(),
                  index_from=0, index_step=1, start_time=1_700_000_000.0,
                  amplitude=1000.0, seed=0, cycle_count=CYCLE_COUNT,
                  counts=None):
    """Write a synthetic capture and return its path.

    `counts` overrides the generated signal with an (rows, 3) array of raw
    counts, which is how a test plants an exact answer. `cycle_count` moves the
    header's gain and is what makes a decimation pair possible: two captures
    whose cycle counts differ by an integer factor.
    """
    meta = {
        "rm3100_capture": 1,
        "nominal_rate_hz": nominal_hz,
        "tmrc_nominal_hz": 600.0,
        "tmrc": "0x92",
        "cycle_count": cycle_count,
        "tesla_per_count": repr(rm3100.tesla_per_count(cycle_count)),
        "i2c_address": "0x23",
        "bus_speed_khz": 750,
        "revid": "0x22",
        "calibrated_period_s": repr(dt),
    }
    meta.update(header or {})
    if drop_header_key:
        meta.pop(drop_header_key, None)

    rng = np.random.default_rng(seed)
    flags = flags or {}
    lines = [f"# {k}: {v}" for k, v in meta.items()]
    lines += list(extra_lines)
    lines.append("sample_index,system_time_unix,x_raw,y_raw,z_raw,warning")
    for i in range(rows):
        index = index_from + i * index_step
        warning = flags.get(i, "")
        if capture.WARN_MISSED in warning:
            x = y = z = 0
        elif counts is not None:
            x, y, z = (int(round(c)) for c in counts[i])
        else:
            x = int(amplitude + rng.normal(0, 3))
            y = int(2 * amplitude + rng.normal(0, 3))
            z = int(-amplitude + rng.normal(0, 3))
        lines.append(f"{index},{start_time + index * dt:.6f},{x},{y},{z},{warning}")
    path.write_text("\n".join(lines) + "\n")
    return str(path)


def field_counts(rows, mean_nt, cycle_count, noise_nt=0.0, seed=0, tones=()):
    """Raw counts for a field of a given mean, noise and planted tones.

    `mean_nt` is an (x, y, z) field in nanotesla; `tones` is a sequence of
    (axis_index, cycles_per_sample, amplitude_nT) added on top. Quantisation to
    integer counts is deliberate -- it is what the real file carries, and a test
    that skipped it would not exercise the dither the analysis relies on.
    """
    lsb = rm3100.tesla_per_count(cycle_count) * rm3100.NT_PER_TESLA
    rng = np.random.default_rng(seed)
    n = np.arange(rows)
    nt = np.tile(np.asarray(mean_nt, dtype=float), (rows, 1))
    if noise_nt:
        nt += rng.normal(0, noise_nt, size=(rows, 3))
    for axis, cycles_per_sample, amplitude in tones:
        nt[:, axis] += amplitude * np.cos(2 * np.pi * cycles_per_sample * n)
    return nt / lsb
