// lr1121_malnus.hpp — minimal LR1121 LoRa driver (Linux, header-only).
//
// Defaults: SPI /dev/spidev0.0, BUSY GPIO24, RESET GPIO25, DIO9 GPIO4.
// RF switch is driven by the chip via DIO5/DIO6.
//
// The driver never logs — failures return false (or a negative code); read
// lastError()/errorString() for the reason. Callers decide what to print.
//
// Refs: Semtech LR1121 user manual rev 1.2, SWDR001, RadioLib LR11x0.
#pragma once

#include <chrono>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <thread>

#include <fcntl.h>
#include <linux/gpio.h>
#include <linux/spi/spidev.h>
#include <sys/ioctl.h>
#include <unistd.h>

namespace lr1121 {

// Opcodes (Semtech LR1121 user manual rev 1.2).
constexpr uint16_t
    OC_GET_VERSION    = 0x0101, OC_WRITE_BUF8        = 0x0109,
    OC_READ_BUF8      = 0x010A, OC_CLEAR_RXBUF       = 0x010B,
    OC_GET_ERRORS     = 0x010D, OC_CLEAR_ERRORS      = 0x010E,
    OC_CALIBRATE      = 0x010F, OC_SET_REGMODE       = 0x0110,
    OC_CALIBRATE_IMG  = 0x0111, OC_SET_DIO_AS_RFSW   = 0x0112,
    OC_SET_DIOIRQ     = 0x0113, OC_CLEAR_IRQ         = 0x0114,
    OC_GET_IRQ        = 0x0115, OC_REBOOT            = 0x0118,
    OC_GET_VBAT       = 0x0119, OC_SET_STANDBY       = 0x011C,
    OC_GET_RXBUF_STA  = 0x0203, OC_GET_PKT_STATUS    = 0x0204,
    OC_SET_LORA_NET   = 0x0208, OC_SET_RX            = 0x0209,
    OC_SET_TX         = 0x020A, OC_SET_RF_FREQ       = 0x020B,
    OC_SET_PKT_TYPE   = 0x020E, OC_SET_MOD_PARAM     = 0x020F,
    OC_SET_PKT_PARAM  = 0x0210, OC_SET_TX_PARAMS     = 0x0211,
    OC_SET_PKT_ADRS   = 0x0212, OC_SET_FALLBACK_MODE = 0x0213,
    OC_SET_PA_CFG     = 0x0215;

constexpr uint8_t  CALIB_ALL = 0x3F;
constexpr uint32_t IRQ_TX_DONE = 1u << 2,  IRQ_RX_DONE = 1u << 3,
                   IRQ_CRC_ERR = 1u << 7,  IRQ_TIMEOUT = 1u << 10,
                   IRQ_LBD     = 1u << 21, IRQ_ALL     = 0x1BF80FFCu;

constexpr uint32_t FREQ_433  = 433'050'000u,
                   FREQ_868  = 868'000'000u,
                   FREQ_2400 = 2'403'000'000u;

constexpr uint8_t  PA_LP = 0x00, PA_HP = 0x01, PKT_TYPE_LORA = 0x02;
constexpr uint8_t  LORA_HEADER_EXPLICIT = 0x00, LORA_CRC_ON = 0x01, LORA_IQ_STD = 0x00;
constexpr uint16_t LORA_PREAMBLE_LEN    = 8;
constexpr uint8_t  TX_RAMP_48US         = 0x02;
constexpr uint32_t TX_TIMEOUT_MS        = 3000, TX_POLL_GUARD_MS = 500;
constexpr int8_t   MIN_TX_DBM_FALLBACK  = 2;

enum class Error : uint8_t {
    Ok = 0,
    NotReady,
    SpiOpen,
    SpiIo,
    GpioOpen,
    BusyTimeout,
    BadChip,
    TxTimeout,
    TxLbd,
    RxTimeout,
    RxCrc,
};

struct Config {
    const char *spi_path   = "/dev/spidev0.0";
    uint32_t    spi_hz     = 8'000'000;
    const char *gpio_chip  = "/dev/gpiochip0";
    unsigned    busy_gpio  = 24;
    unsigned    reset_gpio = 25;
    unsigned    dio9_gpio  = 4;
    uint32_t    freq_hz    = FREQ_433;
    uint8_t     sf         = 7;
    uint8_t     bw         = 0x04;   // 125 kHz
    uint8_t     cr         = 0x01;   // 4/5
    int8_t      tx_dbm     = 10;
    uint8_t     pa_sel     = PA_LP;  // LP avoids LBD on weak VBAT
    uint8_t     pa_supply  = 0x00;
    bool        use_dcdc   = false;
    bool        lora_wan   = false;
};

struct RxInfo {
    int8_t rssi_dbm        = 0;
    int8_t snr_db          = 0;
    int8_t signal_rssi_dbm = 0;
};

struct ChipVersion {
    uint8_t hw    = 0;
    uint8_t type  = 0;
    uint8_t fw_hi = 0;
    uint8_t fw_lo = 0;
};

class Radio {
public:
    bool begin(const Config &cfg);
    bool beginRaw(const Config &cfg);
    bool softResetSettings();
    void end();

    // Returns true on TX_DONE, false otherwise; check lastError() for reason.
    bool send(const uint8_t *data, uint8_t n);

    // One-shot RX: arm, wait timeout_ms, return to standby.
    // Returns bytes received (>=0), -1 on timeout/error, -2 on CRC error.
    int  receive(uint8_t *buf, uint8_t cap, uint32_t timeout_ms,
                 RxInfo *rx_info = nullptr);

    // Continuous RX: arm once, call receive() in a loop — chip stays in RX
    // between packets. timeout_ms in receive() becomes a per-call poll window
    // (0 = wait forever). Call stopListening() to return to standby.
    bool startListening();
    void stopListening();

    // Runtime tuning. Call from standby (the driver leaves the chip in
    // standby_xosc after begin()/send()/receive()).
    bool setFrequency(uint32_t hz);
    bool setTxPower(int8_t dbm, uint8_t pa_sel = PA_LP);
    bool setModulation(uint8_t sf, uint8_t bw, uint8_t cr);

    // Diagnostics — read on demand, never side-effects.
    ChipVersion chipVersion();
    uint16_t    chipErrors();
    uint8_t     vbatRaw();
    float       vbatVolts() { return vbatRaw() / 56.4f; }
    const Config &config() const { return cfg_; }
    Error       lastError() const { return last_err_; }
    static const char *errorString(Error e);

    void reboot(bool stay_in_bootloader = false);

private:
    Config cfg_{};
    int  spi_fd_ = -1, busy_fd_ = -1, reset_fd_ = -1, dio9_fd_ = -1;
    bool listening_ = false;
    Error last_err_ = Error::NotReady;

    bool fail(Error e) { last_err_ = e; return false; }

    bool openSpi();
    bool openGpio(unsigned line, bool out, int &fd_out);
    int  readGpio(int fd);
    void writeGpio(int fd, int value);
    void hardReset();
    bool waitBusy(int timeout_ms = 1000);

    bool spiTransfer(uint8_t *buf, size_t len);
    bool wcmd(uint16_t op, const uint8_t *params = nullptr, size_t n = 0);
    bool wcmd(uint16_t op, std::initializer_list<uint8_t> il);
    bool rcmd(uint16_t op, const uint8_t *params, size_t np,
              uint8_t *out, size_t nr);

    bool setStandbyXosc() { return wcmd(OC_SET_STANDBY, {0x01}); }
    bool setStandbyRc()   { return wcmd(OC_SET_STANDBY, {0x00}); }
    bool setIrqMask(uint32_t irq1, uint32_t irq2 = 0);
    bool clearIrq(uint32_t mask = IRQ_ALL);
    uint32_t getIrq();
    bool applyRadioSettings();
    bool writePktParam(uint8_t payload_len);
    bool writePaCfg(uint8_t pa_sel, int8_t dbm);

    static void     imgCalFreqs(uint32_t hz, uint8_t &f1, uint8_t &f2);
    static uint8_t  computeLdRo(uint8_t sf, uint8_t bw);
    static uint32_t timeoutMsToRtcSteps(uint32_t ms);
    static void     computePaConfig(uint8_t pa_sel, int8_t dbm,
                                    uint8_t &duty, uint8_t &hp_max);
};

// ---------- SPI / command primitives ----------

inline bool Radio::spiTransfer(uint8_t *buf, size_t len)
{
    spi_ioc_transfer tr{};
    tr.tx_buf = reinterpret_cast<uint64_t>(buf);
    tr.rx_buf = reinterpret_cast<uint64_t>(buf);
    tr.len = static_cast<uint32_t>(len);
    tr.speed_hz = cfg_.spi_hz;
    tr.bits_per_word = 8;
    return ioctl(spi_fd_, SPI_IOC_MESSAGE(1), &tr) >= 0;
}

inline bool Radio::wcmd(uint16_t op, const uint8_t *params, size_t n)
{
    if (n > 256) return false;
    if (!waitBusy()) return fail(Error::BusyTimeout);
    uint8_t cmd[258]{};
    cmd[0] = uint8_t(op >> 8);
    cmd[1] = uint8_t(op);
    if (params && n) std::memcpy(cmd + 2, params, n);
    if (!spiTransfer(cmd, 2 + n)) return fail(Error::SpiIo);
    if (!waitBusy()) return fail(Error::BusyTimeout);
    return true;
}

inline bool Radio::wcmd(uint16_t op, std::initializer_list<uint8_t> il)
{
    return wcmd(op, il.begin(), il.size());
}

inline bool Radio::rcmd(uint16_t op, const uint8_t *params, size_t np,
                        uint8_t *out, size_t nr)
{
    if (np > 30 || nr > 259) return false;
    if (!waitBusy()) return fail(Error::BusyTimeout);
    uint8_t cmd[32]{};
    cmd[0] = uint8_t(op >> 8);
    cmd[1] = uint8_t(op);
    if (params && np) std::memcpy(cmd + 2, params, np);
    if (!spiTransfer(cmd, 2 + np)) return fail(Error::SpiIo);
    if (!waitBusy()) return fail(Error::BusyTimeout);
    uint8_t rsp[260]{};
    if (!spiTransfer(rsp, nr + 1)) return fail(Error::SpiIo);
    std::memcpy(out, rsp + 1, nr);
    return true;
}

inline bool Radio::setIrqMask(uint32_t irq1, uint32_t irq2)
{
    return wcmd(OC_SET_DIOIRQ, {
        uint8_t(irq1 >> 24), uint8_t(irq1 >> 16), uint8_t(irq1 >> 8), uint8_t(irq1),
        uint8_t(irq2 >> 24), uint8_t(irq2 >> 16), uint8_t(irq2 >> 8), uint8_t(irq2),
    });
}

inline bool Radio::clearIrq(uint32_t mask)
{
    return wcmd(OC_CLEAR_IRQ, {
        uint8_t(mask >> 24), uint8_t(mask >> 16), uint8_t(mask >> 8), uint8_t(mask),
    });
}

inline uint32_t Radio::getIrq()
{
    uint8_t b[4]{};
    if (!rcmd(OC_GET_IRQ, nullptr, 0, b, sizeof(b))) return 0;
    return (uint32_t(b[0]) << 24) | (uint32_t(b[1]) << 16) |
           (uint32_t(b[2]) <<  8) |  uint32_t(b[3]);
}

// ---------- SPI / GPIO open ----------

inline bool Radio::openSpi()
{
    spi_fd_ = ::open(cfg_.spi_path, O_RDWR);
    if (spi_fd_ < 0) return fail(Error::SpiOpen);

    uint8_t mode = SPI_MODE_0, bits = 8;
    if (ioctl(spi_fd_, SPI_IOC_WR_MODE,          &mode)        < 0 ||
        ioctl(spi_fd_, SPI_IOC_WR_BITS_PER_WORD, &bits)        < 0 ||
        ioctl(spi_fd_, SPI_IOC_WR_MAX_SPEED_HZ,  &cfg_.spi_hz) < 0) {
        ::close(spi_fd_); spi_fd_ = -1;
        return fail(Error::SpiOpen);
    }
    return true;
}

inline bool Radio::openGpio(unsigned line, bool out, int &fd_out)
{
    int chip = ::open(cfg_.gpio_chip, O_RDWR);
    if (chip < 0) return fail(Error::GpioOpen);

    gpiohandle_request req{};
    req.lineoffsets[0]    = line;
    req.lines             = 1;
    req.flags             = out ? GPIOHANDLE_REQUEST_OUTPUT : GPIOHANDLE_REQUEST_INPUT;
    req.default_values[0] = out ? 1 : 0;
    std::strncpy(req.consumer_label, "lr1121", sizeof(req.consumer_label) - 1);

    const int rc = ioctl(chip, GPIO_GET_LINEHANDLE_IOCTL, &req);
    ::close(chip);
    if (rc < 0) return fail(Error::GpioOpen);
    fd_out = req.fd;
    return true;
}

inline int Radio::readGpio(int fd)
{
    gpiohandle_data d{};
    if (ioctl(fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &d) < 0) return 1;
    return d.values[0];
}

inline void Radio::writeGpio(int fd, int value)
{
    gpiohandle_data d{};
    d.values[0] = value;
    (void)ioctl(fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &d);
}

inline void Radio::hardReset()
{
    writeGpio(reset_fd_, 0);
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    writeGpio(reset_fd_, 1);
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

inline bool Radio::waitBusy(int timeout_ms)
{
    const int loops = timeout_ms * 20;
    for (int i = 0; i < loops; ++i) {
        if (readGpio(busy_fd_) == 0) return true;
        std::this_thread::sleep_for(std::chrono::microseconds(50));
    }
    return false;
}

// ---------- Math helpers ----------

inline void Radio::imgCalFreqs(uint32_t hz, uint8_t &f1, uint8_t &f2)
{
    const uint32_t mhz = hz / 1'000'000u;
    uint32_t lo, hi;
    // 2.4 GHz values are hardcoded (lo/4 would overflow uint8_t).
    if (mhz >= 2000) { f1 = 0xD7; f2 = 0xDB; return; }
    if      (mhz < 446)  { lo = 430;  hi = 440;  }
    else if (mhz < 740)  { lo = 470;  hi = 510;  }
    else if (mhz < 890)  { lo = 860;  hi = 876;  }
    else                 { lo = 902;  hi = 928;  }
    f1 = uint8_t(lo / 4);
    f2 = uint8_t((hi + 3) / 4);
}

inline uint8_t Radio::computeLdRo(uint8_t sf, uint8_t bw)
{
    static const uint32_t bw_hz[] = {0, 15625, 31250, 62500, 125000, 250000, 500000};
    const uint32_t bw_val = (bw < 7) ? bw_hz[bw] : 125000;
    return ((1000u << sf) / bw_val) > 16 ? 1 : 0;
}

inline uint32_t Radio::timeoutMsToRtcSteps(uint32_t ms)
{
    if (ms == 0) return 0x00FFFFFFu;
    const uint64_t steps = (uint64_t(ms) * 32768u) / 1000u;
    return steps > 0x00FFFFFFu ? 0x00FFFFFFu : uint32_t(steps);
}

inline void Radio::computePaConfig(uint8_t pa_sel, int8_t dbm,
                                   uint8_t &duty, uint8_t &hp_max)
{
    if (pa_sel == PA_HP) {
        if      (dbm >= 22) { duty = 4; hp_max = 7; }
        else if (dbm >= 20) { duty = 3; hp_max = 5; }
        else if (dbm >= 17) { duty = 2; hp_max = 3; }
        else if (dbm >= 14) { duty = 2; hp_max = 2; }
        else if (dbm >= 10) { duty = 1; hp_max = 1; }
        else                { duty = 0; hp_max = 0; }
    } else {
        hp_max = 0;
        if      (dbm >= 14) duty = 7;
        else if (dbm >= 10) duty = 4;
        else if (dbm >=  0) duty = 2;
        else                duty = 0;
    }
}

inline bool Radio::writePktParam(uint8_t payload_len)
{
    return wcmd(OC_SET_PKT_PARAM, {
        uint8_t(LORA_PREAMBLE_LEN >> 8),
        uint8_t(LORA_PREAMBLE_LEN),
        LORA_HEADER_EXPLICIT,
        payload_len,
        LORA_CRC_ON,
        LORA_IQ_STD,
    });
}

inline bool Radio::writePaCfg(uint8_t pa_sel, int8_t dbm)
{
    uint8_t duty = 0, hp_max = 0;
    computePaConfig(pa_sel, dbm, duty, hp_max);
    return wcmd(OC_SET_PA_CFG, {pa_sel, cfg_.pa_supply, duty, hp_max});
}

// ---------- Diagnostics ----------

inline ChipVersion Radio::chipVersion()
{
    uint8_t v[4]{};
    (void)rcmd(OC_GET_VERSION, nullptr, 0, v, sizeof(v));
    return {v[0], v[1], v[2], v[3]};
}

inline uint16_t Radio::chipErrors()
{
    uint8_t e[2]{};
    if (!rcmd(OC_GET_ERRORS, nullptr, 0, e, sizeof(e))) return 0xFFFF;
    return uint16_t((e[0] << 8) | e[1]);
}

inline uint8_t Radio::vbatRaw()
{
    uint8_t raw = 0;
    (void)rcmd(OC_GET_VBAT, nullptr, 0, &raw, 1);
    return raw;
}

inline const char *Radio::errorString(Error e)
{
    static constexpr const char *names[] = {
        "ok", "not initialised", "spi open failed", "spi io failed",
        "gpio open failed", "busy line never released", "chip is not LR1121",
        "tx timeout", "tx low-battery detect", "rx timeout", "rx crc error",
    };
    const size_t i = size_t(e);
    return i < (sizeof(names) / sizeof(*names)) ? names[i] : "unknown";
}

// ---------- Lifecycle ----------

inline bool Radio::beginRaw(const Config &cfg)
{
    end();
    cfg_ = cfg;
    last_err_ = Error::Ok;
    if (!openSpi())                                       return false;
    if (!openGpio(cfg_.reset_gpio, true,  reset_fd_)) { end(); return false; }
    if (!openGpio(cfg_.busy_gpio,  false, busy_fd_))  { end(); return false; }
    if (!openGpio(cfg_.dio9_gpio,  false, dio9_fd_))  { end(); return false; }
    hardReset();
    if (!waitBusy(500)) { last_err_ = Error::BusyTimeout; end(); return false; }
    return true;
}

inline bool Radio::begin(const Config &cfg)
{
    if (!beginRaw(cfg)) return false;
    const ChipVersion v = chipVersion();
    if (v.type != 0x03) { last_err_ = Error::BadChip; end(); return false; }
    return applyRadioSettings();
}

inline bool Radio::applyRadioSettings()
{
    if (!setStandbyRc())                                  { end(); return false; }
    if (!wcmd(OC_SET_FALLBACK_MODE, {0x01}))              { end(); return false; }
    if (!clearIrq())                                      { end(); return false; }
    if (!setIrqMask(0, 0))                                { end(); return false; }
    if (!wcmd(OC_CALIBRATE, {CALIB_ALL}))                 { end(); return false; }
    (void)wcmd(OC_CLEAR_ERRORS);

    if (!wcmd(OC_SET_REGMODE,  {uint8_t(cfg_.use_dcdc)})) { end(); return false; }
    if (!wcmd(OC_SET_PKT_TYPE, {PKT_TYPE_LORA}))          { end(); return false; }
    if (!setFrequency(cfg_.freq_hz))                      { end(); return false; }
    if (!wcmd(OC_SET_PKT_ADRS, {0x00, 0x00}))             { end(); return false; }
    if (!setModulation(cfg_.sf, cfg_.bw, cfg_.cr))        { end(); return false; }
    if (!writePktParam(0xFF))                             { end(); return false; }
    if (!setTxPower(cfg_.tx_dbm, cfg_.pa_sel))            { end(); return false; }
    if (!wcmd(OC_SET_LORA_NET, {uint8_t(cfg_.lora_wan)})) { end(); return false; }

    // DIO5/DIO6 RF switch: STBY(0,0), RX(1,0), TX(0,1), TX_HP(0,1)
    if (!wcmd(OC_SET_DIO_AS_RFSW, {0x03, 0, 0x01, 0x02, 0x02, 0, 0, 0})) {
        end(); return false;
    }
    if (!setStandbyXosc())                                { end(); return false; }
    return true;
}

inline bool Radio::setFrequency(uint32_t hz)
{
    if (!wcmd(OC_SET_RF_FREQ, {
            uint8_t(hz >> 24), uint8_t(hz >> 16),
            uint8_t(hz >> 8),  uint8_t(hz),
        })) return false;
    uint8_t f1, f2;
    imgCalFreqs(hz, f1, f2);
    if (!wcmd(OC_CALIBRATE_IMG, {f1, f2})) return false;
    cfg_.freq_hz = hz;
    return true;
}

inline bool Radio::setTxPower(int8_t dbm, uint8_t pa_sel)
{
    if (!writePaCfg(pa_sel, dbm)) return false;
    if (!wcmd(OC_SET_TX_PARAMS, {uint8_t(dbm), TX_RAMP_48US})) return false;
    cfg_.pa_sel = pa_sel;
    cfg_.tx_dbm = dbm;
    return true;
}

inline bool Radio::setModulation(uint8_t sf, uint8_t bw, uint8_t cr)
{
    if (!wcmd(OC_SET_MOD_PARAM, {sf, bw, cr, computeLdRo(sf, bw)})) return false;
    cfg_.sf = sf;
    cfg_.bw = bw;
    cfg_.cr = cr;
    return true;
}

inline bool Radio::softResetSettings()
{
    if (spi_fd_ < 0 || busy_fd_ < 0 || reset_fd_ < 0 || dio9_fd_ < 0)
        return fail(Error::NotReady);
    if (!setStandbyRc()) return false;
    (void)wcmd(OC_CLEAR_RXBUF);
    (void)clearIrq();
    return applyRadioSettings();
}

inline void Radio::reboot(bool stay_in_bootloader)
{
    (void)wcmd(OC_REBOOT, {uint8_t(stay_in_bootloader ? 0x01 : 0x00)});
    std::this_thread::sleep_for(std::chrono::milliseconds(300));
}

inline void Radio::end()
{
    if (spi_fd_   >= 0) { ::close(spi_fd_);   spi_fd_   = -1; }
    if (busy_fd_  >= 0) { ::close(busy_fd_);  busy_fd_  = -1; }
    if (reset_fd_ >= 0) { ::close(reset_fd_); reset_fd_ = -1; }
    if (dio9_fd_  >= 0) { ::close(dio9_fd_);  dio9_fd_  = -1; }
}

// ---------- TX / RX ----------

inline bool Radio::send(const uint8_t *data, uint8_t n)
{
    if (n == 0) return fail(Error::TxTimeout);
    uint8_t pa_sel = cfg_.pa_sel;
    int8_t  tx_dbm = cfg_.tx_dbm;

    for (int attempt = 0; attempt < 6; ++attempt) {
        if (!setStandbyXosc()) return false;
        if (!clearIrq())       return false;
        const uint32_t mask = IRQ_TX_DONE | IRQ_TIMEOUT | IRQ_LBD;
        if (!setIrqMask(mask, mask)) return false;

        if (!writePaCfg(pa_sel, tx_dbm)) return false;
        if (!wcmd(OC_SET_TX_PARAMS, {uint8_t(tx_dbm), TX_RAMP_48US})) return false;
        if (!wcmd(OC_WRITE_BUF8, data, n)) return false;
        if (!writePktParam(n)) return false;

        const uint32_t tx_steps = timeoutMsToRtcSteps(TX_TIMEOUT_MS);
        if (!wcmd(OC_SET_TX, {
                uint8_t(tx_steps >> 16),
                uint8_t(tx_steps >> 8),
                uint8_t(tx_steps),
            })) return false;

        const auto deadline = std::chrono::steady_clock::now() +
            std::chrono::milliseconds(TX_TIMEOUT_MS + TX_POLL_GUARD_MS);

        bool lbd_retry = false;
        while (std::chrono::steady_clock::now() < deadline) {
            if (readGpio(dio9_fd_) == 0) {
                std::this_thread::sleep_for(std::chrono::milliseconds(1));
                continue;
            }
            const uint32_t irq = getIrq();
            if (irq & IRQ_TX_DONE) {
                (void)clearIrq();
                (void)setIrqMask(0, 0);
                last_err_ = Error::Ok;
                return true;
            }
            if (irq & IRQ_TIMEOUT) {
                (void)clearIrq();
                (void)setIrqMask(0, 0);
                return fail(Error::TxTimeout);
            }
            if (irq & IRQ_LBD) {
                (void)clearIrq();
                (void)setIrqMask(0, 0);
                if (pa_sel == PA_HP) {
                    pa_sel = PA_LP;
                    if (tx_dbm > 10) tx_dbm = 10;
                } else if (tx_dbm > MIN_TX_DBM_FALLBACK) {
                    tx_dbm = int8_t(tx_dbm - 2);
                } else {
                    return fail(Error::TxLbd);
                }
                lbd_retry = true;
                break;
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }

        if (!lbd_retry) {
            (void)clearIrq();
            (void)setIrqMask(0, 0);
            return fail(Error::TxTimeout);
        }
    }
    return fail(Error::TxLbd);
}

inline bool Radio::startListening()
{
    if (!setStandbyXosc()) return false;
    if (!clearIrq())       return false;
    const uint32_t mask = IRQ_RX_DONE | IRQ_CRC_ERR;
    if (!setIrqMask(mask, mask)) return false;
    // 0xFFFFFF = continuous RX, no radio timeout
    if (!wcmd(OC_SET_RX, {0xFF, 0xFF, 0xFF})) return false;
    listening_ = true;
    return true;
}

inline void Radio::stopListening()
{
    (void)setStandbyXosc();
    (void)clearIrq();
    (void)setIrqMask(0, 0);
    listening_ = false;
}

inline int Radio::receive(uint8_t *buf, uint8_t cap, uint32_t timeout_ms,
                          RxInfo *rx_info)
{
    if (!listening_) {
        // one-shot: arm the chip, it will time out on its own
        if (!setStandbyXosc()) return -1;
        if (!clearIrq())       return -1;
        const uint32_t mask = IRQ_RX_DONE | IRQ_CRC_ERR | IRQ_TIMEOUT;
        if (!setIrqMask(mask, mask)) return -1;
        const uint32_t steps = timeoutMsToRtcSteps(timeout_ms);
        if (!wcmd(OC_SET_RX, {
                uint8_t(steps >> 16),
                uint8_t(steps >> 8),
                uint8_t(steps),
            })) return -1;
    }

    // continuous: timeout_ms is a per-call poll window, 0 means wait forever
    const uint32_t poll_ms = listening_ ? (timeout_ms == 0 ? 0 : timeout_ms)
                                        : (timeout_ms == 0 ? 60000 : timeout_ms + 500);
    const bool has_deadline = poll_ms > 0;
    const auto deadline = std::chrono::steady_clock::now() +
        std::chrono::milliseconds(poll_ms);

    for (;;) {
        if (has_deadline && std::chrono::steady_clock::now() >= deadline) {
            if (!listening_) { (void)clearIrq(); (void)setIrqMask(0, 0); }
            last_err_ = Error::RxTimeout;
            return -1;
        }
        if (readGpio(dio9_fd_) == 0) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
            continue;
        }
        const uint32_t irq = getIrq();
        if (irq & IRQ_TIMEOUT) {
            if (!listening_) { (void)clearIrq(); (void)setIrqMask(0, 0); }
            last_err_ = Error::RxTimeout;
            return -1;
        }
        if ((irq & (IRQ_RX_DONE | IRQ_CRC_ERR)) == 0) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
            continue;
        }

        if (rx_info) {
            uint8_t p[3]{};
            if (rcmd(OC_GET_PKT_STATUS, nullptr, 0, p, sizeof(p))) {
                rx_info->rssi_dbm        = int8_t(-int(p[0]) / 2);
                rx_info->snr_db          = int8_t(int8_t(p[1]) / 4);
                rx_info->signal_rssi_dbm = int8_t(-int(p[2]) / 2);
            }
        }

        uint8_t st[2]{};
        if (!rcmd(OC_GET_RXBUF_STA, nullptr, 0, st, sizeof(st))) return -1;
        const uint8_t len = st[0] < cap ? st[0] : cap;
        const uint8_t rp[] = {st[1], len};
        if (len > 0 && !rcmd(OC_READ_BUF8, rp, sizeof(rp), buf, len)) return -1;

        (void)wcmd(OC_CLEAR_RXBUF);
        (void)clearIrq();
        if (listening_) {
            // re-arm IRQ mask but leave chip in RX
            const uint32_t mask = IRQ_RX_DONE | IRQ_CRC_ERR;
            (void)setIrqMask(mask, mask);
        } else {
            (void)setIrqMask(0, 0);
        }
        if (irq & IRQ_CRC_ERR) { last_err_ = Error::RxCrc; return -2; }
        last_err_ = Error::Ok;
        return int(len);
    }
}

} // namespace lr1121
