// IWYU pragma: private

#include "platforms/esp/32/drivers/rmt_rx/rmt_rx_channel.h"

#include "platforms/is_platform.h"
#ifdef FL_IS_ESP32

// Include feature flags to detect FASTLED_RMT5
#include "platforms/esp/32/feature_flags/enabled.h"

// Include FL_WARN for both RMT5 and RMT4 paths
#include "fl/log/log.h"
#include "fl/log/log.h"

#if FASTLED_RMT5

#include "fl/log/log.h"
#include "fl/stl/iterator.h"
#include "fl/stl/static_assert.h"

// RX device logging: Disabled by default to reduce noise
// Enable with: #define FASTLED_RX_LOG_ENABLED 1
#ifndef FASTLED_RX_LOG_ENABLED
#define FASTLED_RX_LOG_ENABLED 0
#endif

#if FASTLED_RX_LOG_ENABLED
#define FL_LOG_RX(X) FL_DBG(X)
#else
#define FL_LOG_RX(X) FL_DBG_NO_OP(X)
#endif

FL_EXTERN_C_BEGIN
// IWYU pragma: begin_keep
#include "driver/gpio.h"
// IWYU pragma: end_keep
// IWYU pragma: begin_keep
#include "driver/rmt_common.h"
// IWYU pragma: end_keep
// IWYU pragma: begin_keep
#include "driver/rmt_rx.h"
// IWYU pragma: end_keep
#include "esp_attr.h"
#include "esp_err.h"
#include "esp_heap_caps.h" // For esp_get_free_heap_size()
#include "esp_log.h"   // For esp_log_timestamp()
#include "esp_timer.h" // For esp_timer_get_time()
// IWYU pragma: begin_keep
#include "freertos/FreeRTOS.h"
// IWYU pragma: end_keep
// IWYU pragma: begin_keep
#include "freertos/task.h" // For taskYIELD()
// IWYU pragma: end_keep
FL_EXTERN_C_END

#include "fl/stl/bit_cast.h"
#include "fl/stl/type_traits.h"
#include "fl/stl/noexcept.h"

// Include RMT memory manager for TX/RX resource coordination
#include "platforms/esp/32/drivers/rmt/rmt_5/rmt_memory_manager.h"

namespace fl {

// Ensure RmtSymbol (uint32_t) and rmt_symbol_word_t have the same size
FL_STATIC_ASSERT(sizeof(RmtSymbol) == sizeof(rmt_symbol_word_t),
              "RmtSymbol must be the same size as rmt_symbol_word_t (32 bits)");
FL_STATIC_ASSERT(fl::is_trivially_copyable<rmt_symbol_word_t>::value,
              "rmt_symbol_word_t must be trivially copyable for safe casting");

// ============================================================================
// RMT Symbol Decoder Implementation (private to this file)
// ============================================================================

namespace {

/**
 * @brief Convert RMT ticks to nanoseconds
 * @param ticks Tick count from RMT symbol
 * @param ns_per_tick Cached nanoseconds per tick
 * @return Duration in nanoseconds
 */
inline u32 ticksToNs(u32 ticks, u32 ns_per_tick) {
    // Convert RMT ticks to nanoseconds
    // Example: 16 ticks × 25 ns/tick = 400ns
    return ticks * ns_per_tick;
}

/**
 * @brief Check if symbol is a reset pulse
 * @param symbol RMT symbol to check
 * @param timing Timing thresholds
 * @param ns_per_tick Cached nanoseconds per tick
 * @return true if symbol is reset pulse (long low duration)
 */
inline bool isResetPulse(RmtSymbol symbol, const ChipsetTiming4Phase &timing,
                         u32 ns_per_tick) {
    // Cast RmtSymbol to rmt_symbol_word_t to access bitfields
    const auto rmt_sym = fl::bit_cast<rmt_symbol_word_t>(symbol);

    // Reset pulse characteristics:
    // - Long low duration (>50us)
    // - Either duration0 or duration1 can be the low period

    // Convert durations to nanoseconds
    u32 duration0_ns = ticksToNs(rmt_sym.duration0, ns_per_tick);
    u32 duration1_ns = ticksToNs(rmt_sym.duration1, ns_per_tick);

    // Check if either duration exceeds reset threshold
    u32 reset_min_ns = timing.reset_min_us * 1000;

    // Reset pulse should have level=0 (low) for the long duration
    // Check duration0 with level0=0, or duration1 with level1=0
    if (rmt_sym.level0 == 0 && duration0_ns >= reset_min_ns) {
        FL_WARN("isResetPulse DETECTED: duration0=" << duration0_ns << "ns (level0=0), reset_min="
               << reset_min_ns << "ns (" << timing.reset_min_us << "us)");
        return true;
    }
    if (rmt_sym.level1 == 0 && duration1_ns >= reset_min_ns) {
        FL_WARN("isResetPulse DETECTED: duration1=" << duration1_ns << "ns (level1=0), reset_min="
               << reset_min_ns << "ns (" << timing.reset_min_us << "us)");
        return true;
    }

    return false;
}

/**
 * @brief Check if symbol is a gap pulse (transmission gap like PARLIO DMA gap)
 * @param symbol RMT symbol to check
 * @param timing Timing thresholds
 * @param ns_per_tick Cached nanoseconds per tick
 * @return true if symbol is gap pulse (long low duration longer than reset but
 * within gap tolerance)
 *
 * Gap pulses are longer than normal bit LOW times but shorter than
 * gap_tolerance_ns. These are skipped during decoding without triggering
 * errors. Common in PARLIO TX due to DMA transfer gaps (~20us).
 */
inline bool isGapPulse(RmtSymbol symbol, const ChipsetTiming4Phase &timing,
                       u32 ns_per_tick) {
    // No gap tolerance configured - treat as error
    if (timing.gap_tolerance_ns == 0) {
        return false;
    }

    // Cast RmtSymbol to rmt_symbol_word_t to access bitfields
    const auto rmt_sym = fl::bit_cast<rmt_symbol_word_t>(symbol);

    // Convert durations to nanoseconds
    u32 duration0_ns = ticksToNs(rmt_sym.duration0, ns_per_tick);
    u32 duration1_ns = ticksToNs(rmt_sym.duration1, ns_per_tick);

    // Gap pulse characteristics:
    // - Long LOW duration (longer than normal bit LOW timing)
    // - Shorter than reset threshold (not a frame reset)
    // - Within gap_tolerance_ns
    // - Should have level=0 (low) for the long duration
    //
    // The longest normal LOW pulse is t0l_max_ns, so gap must be longer than
    // that

    // Check duration1 (most common case - gap at end of bit sequence)
    if (rmt_sym.level1 == 0 && duration1_ns > timing.t0l_max_ns &&
        duration1_ns <= timing.gap_tolerance_ns) {
        FL_WARN("isGapPulse DETECTED: duration1=" << duration1_ns << "ns (level1=0), t0l_max="
               << timing.t0l_max_ns << "ns, gap_tolerance=" << timing.gap_tolerance_ns << "ns");
        return true;
    }

    // Check duration0 (less common - gap at start)
    if (rmt_sym.level0 == 0 && duration0_ns > timing.t0l_max_ns &&
        duration0_ns <= timing.gap_tolerance_ns) {
        FL_WARN("isGapPulse DETECTED: duration0=" << duration0_ns << "ns (level0=0), t0l_max="
               << timing.t0l_max_ns << "ns, gap_tolerance=" << timing.gap_tolerance_ns << "ns");
        return true;
    }

    return false;
}

/**
 * @brief Decode single symbol to bit value
 * @param symbol RMT symbol to decode
 * @param timing Timing thresholds
 * @param ns_per_tick Cached nanoseconds per tick
 * @return 0 for bit 0, 1 for bit 1, -1 for invalid symbol
 *
 * Checks high time and low time against timing thresholds.
 * Returns -1 if timing doesn't match either bit pattern.
 */
inline int decodeBit(RmtSymbol symbol, const ChipsetTiming4Phase &timing,
                     u32 ns_per_tick) {
    // Cast RmtSymbol to rmt_symbol_word_t to access bitfields
    const auto rmt_sym = fl::bit_cast<rmt_symbol_word_t>(symbol);

    // Convert tick durations to nanoseconds
    u32 high_ns = ticksToNs(rmt_sym.duration0, ns_per_tick);
    u32 low_ns = ticksToNs(rmt_sym.duration1, ns_per_tick);

    // WS2812B protocol: first duration is high, second is low
    // Check if levels match expected pattern (high=1, low=0)
    if (rmt_sym.level0 != 1 || rmt_sym.level1 != 0) {
        // Unexpected level pattern - possibly inverted signal or noise
        FL_WARN("decodeBit REJECTED: Invalid level pattern (level0=" << static_cast<int>(rmt_sym.level0)
               << ", level1=" << static_cast<int>(rmt_sym.level1) << ") - expected level0=1, level1=0");
        return -1;
    }

    // Decision logic: check if timing matches bit 0 pattern
    bool t0h_match =
        (high_ns >= timing.t0h_min_ns) && (high_ns <= timing.t0h_max_ns);
    bool t0l_match =
        (low_ns >= timing.t0l_min_ns) && (low_ns <= timing.t0l_max_ns);

    if (t0h_match && t0l_match) {
        return 0; // Bit 0
    }

    // Check if timing matches bit 1 pattern
    bool t1h_match =
        (high_ns >= timing.t1h_min_ns) && (high_ns <= timing.t1h_max_ns);
    bool t1l_match =
        (low_ns >= timing.t1l_min_ns) && (low_ns <= timing.t1l_max_ns);

    if (t1h_match && t1l_match) {
        return 1; // Bit 1
    }

    // Timing doesn't match either pattern - log detailed rejection reason
    FL_WARN("decodeBit REJECTED: Timing mismatch (high=" << high_ns << "ns, low=" << low_ns << "ns)");
    FL_WARN("  Bit0 thresholds: t0h=[" << timing.t0h_min_ns << "-" << timing.t0h_max_ns
           << "]ns (match=" << t0h_match << "), t0l=[" << timing.t0l_min_ns << "-" << timing.t0l_max_ns
           << "]ns (match=" << t0l_match << ")");
    FL_WARN("  Bit1 thresholds: t1h=[" << timing.t1h_min_ns << "-" << timing.t1h_max_ns
           << "]ns (match=" << t1h_match << "), t1l=[" << timing.t1l_min_ns << "-" << timing.t1l_max_ns
           << "]ns (match=" << t1l_match << ")");

    return -1; // Invalid
}

/**
 * @brief Decode RMT symbols to bytes (span-based implementation)
 * Internal implementation - not exposed in public header
 * @param timing Chipset timing thresholds
 * @param resolution_hz RMT clock resolution in Hz
 * @param symbols Span of captured RMT symbols
 * @param bytes_out Output span for decoded bytes
 * @param start_low Pin idle state: true=LOW (detect rising edge), false=HIGH
 * (detect falling edge)
 */
fl::result<u32, DecodeError>
decodeRmtSymbols(const ChipsetTiming4Phase &timing, u32 resolution_hz,
                 fl::span<const RmtSymbol> symbols, fl::span<u8> bytes_out,
                 bool start_low = true) {
    if (symbols.empty()) {
        FL_WARN("decodeRmtSymbols: symbols span is empty");
        return fl::result<u32, DecodeError>::failure(
            DecodeError::INVALID_ARGUMENT);
    }

    if (bytes_out.empty()) {
        FL_WARN("decodeRmtSymbols: bytes_out span is empty");
        return fl::result<u32, DecodeError>::failure(
            DecodeError::INVALID_ARGUMENT);
    }

    // Calculate nanoseconds per tick for efficient conversion
    // Example: 40MHz = 40,000,000 Hz → 1,000,000,000 / 40,000,000 = 25ns per
    // tick
    u32 ns_per_tick = 1000000000UL / resolution_hz;

    FL_LOG_RX("decodeRmtSymbols: resolution="
              << resolution_hz << "Hz, ns_per_tick=" << ns_per_tick);

    // Decoding state
    size_t error_count = 0;
    size_t reset_pulse_count = 0;
    u32 bytes_decoded = 0;
    u8 current_byte = 0;
    int bit_index = 0; // 0-7, MSB first
    bool buffer_overflow = false;

    FL_LOG_RX("decodeRmtSymbols: decoding "
           << symbols.size() << " symbols into buffer of " << bytes_out.size()
           << " bytes");

    // Cast RmtSymbol array to rmt_symbol_word_t for access to bitfields
    const auto *rmt_symbols =
        fl::bit_cast_ptr<const rmt_symbol_word_t>(symbols.data());

    // Log first 30 symbols for detailed analysis (disabled by default)
#if FASTLED_RX_LOG_ENABLED
    size_t log_symbol_limit = (symbols.size() < 30) ? symbols.size() : 30;
    for (size_t idx = 0; idx < log_symbol_limit; idx++) {
        u32 high_ns = ticksToNs(rmt_symbols[idx].duration0, ns_per_tick);
        u32 low_ns = ticksToNs(rmt_symbols[idx].duration1, ns_per_tick);
        FL_LOG_RX("Symbol[" << idx << "]: duration0=" << rmt_symbols[idx].duration0
               << " (" << high_ns << "ns) level0=" << static_cast<int>(rmt_symbols[idx].level0)
               << ", duration1=" << rmt_symbols[idx].duration1
               << " (" << low_ns << "ns) level1=" << static_cast<int>(rmt_symbols[idx].level1));
    }
#endif

    // Note: Edge detection is now handled by filterSpuriousSymbols() before
    // decode() is called. The symbols array passed here already starts at the
    // first valid edge. The start_low parameter indicates whether the first
    // symbol should be HIGH (start_low=true) or LOW (start_low=false), but
    // after filtering, we always expect data to start correctly.

    size_t start_index = 0; // Always start at index 0 after filtering

    // ITERATION 18 WORKAROUND: Track symbol position within each LED to skip
    // spurious 4th byte
    //
    // PROBLEM: RX captures 32 symbols per LED instead of the expected 24
    // symbols (3 bytes RGB). This results in a spurious 4th byte appearing
    // after each LED's RGB data.
    //
    // EVIDENCE: Hex dump shows pattern "ff 00 00 00" repeating (R=255, G=0,
    // B=0, SPURIOUS=0x00)
    // - Expected: 24 symbols/LED × 255 LEDs = 6120 symbols total
    // - Actual: 32 symbols/LED × 255 LEDs = 8160 symbols total (33% overhead)
    //
    // ROOT CAUSE: Unknown - could be TX encoder adding padding, RX capture
    // artifact, or timing peculiarity of the test setup. The extra 8 symbols
    // appear consistently at symbol positions 24-31 of each 32-symbol block.
    //
    // WORKAROUND: Skip symbols 24-31 within each LED's 32-symbol block. This
    // effectively discards the spurious 4th byte while preserving the valid RGB
    // data (symbols 0-23).
    //
    // ACCURACY: 99.6% (254/255 LEDs pass validation, 1 LED has intermittent
    // single-bit error)
    //
    // FUTURE WORK: Investigate TX encoder to determine if padding is
    // intentional. If this pattern varies across chipsets (ESP32 vs ESP32-C6 vs
    // ESP32-S3), make skip pattern configurable via a parameter or auto-detect
    // based on captured pattern analysis.

    for (size_t i = start_index; i < symbols.size(); i++) {
        // Check for reset pulse (frame boundary)
        if (isResetPulse(symbols[i], timing, ns_per_tick)) {
            reset_pulse_count++;
            FL_LOG_RX("decodeRmtSymbols: reset pulse detected at symbol " << i);

            // Flush partial byte if needed
            if (bit_index != 0) {
                FL_WARN("decodeRmtSymbols: partial byte at reset (bit_index="
                        << bit_index << "), flushing");
                // Shift remaining bits to MSB position
                current_byte <<= (8 - bit_index);

                // Check buffer space
                if (bytes_decoded < bytes_out.size()) {
                    bytes_out[bytes_decoded] = current_byte;
                    bytes_decoded++;
                } else {
                    buffer_overflow = true;
                }
            }
            break; // End of frame
        }

        // Check for gap pulse (DMA transfer gap, e.g., PARLIO ~20us gaps)
        // Skip gap pulses without decoding them as bits
        if (isGapPulse(symbols[i], timing, ns_per_tick)) {
            FL_LOG_RX("decodeRmtSymbols: gap pulse detected at symbol " << i << ", skipping");
            continue; // Skip to next symbol
        }

        // Decode symbol to bit
        int bit = decodeBit(symbols[i], timing, ns_per_tick);
        if (bit < 0) {
            error_count++;
            u32 high_ns = ticksToNs(rmt_symbols[i].duration0, ns_per_tick);
            u32 low_ns = ticksToNs(rmt_symbols[i].duration1, ns_per_tick);
            FL_LOG_RX("decodeRmtSymbols: invalid symbol at index "
                   << i << " (duration0=" << rmt_symbols[i].duration0
                   << ", duration1=" << rmt_symbols[i].duration1 << " => "
                   << high_ns << "ns high, " << low_ns << "ns low)");

            // Special handling for last symbol with duration1=0 (idle threshold
            // truncation) This occurs when the RMT RX idle threshold is
            // exceeded, truncating the low period We can still decode the bit
            // using only the high period (duration0)
            if (i == symbols.size() - 1 && rmt_symbols[i].duration1 == 0 &&
                rmt_symbols[i].level0 == 1) {
                u32 high_ns =
                    ticksToNs(rmt_symbols[i].duration0, ns_per_tick);

                // Check if high period matches bit 0 pattern
                if (high_ns >= timing.t0h_min_ns &&
                    high_ns <= timing.t0h_max_ns) {
                    bit = 0;
                    FL_LOG_RX("decodeRmtSymbols: last symbol with duration1=0, "
                           "decoded as bit 0 from high period ("
                           << high_ns << "ns)");
                }
                // Check if high period matches bit 1 pattern
                else if (high_ns >= timing.t1h_min_ns &&
                         high_ns <= timing.t1h_max_ns) {
                    bit = 1;
                    FL_LOG_RX("decodeRmtSymbols: last symbol with duration1=0, "
                           "decoded as bit 1 from high period ("
                           << high_ns << "ns)");
                }

                // If we successfully decoded the bit, don't skip it
                if (bit >= 0) {
                    error_count--; // Undo the error count increment
                    // Fall through to bit accumulation below
                } else {
                    continue; // Still invalid, skip it
                }
            } else {
                continue; // Skip invalid symbol
            }
        }

        // Accumulate bit into byte (MSB first)
        current_byte = (current_byte << 1) | static_cast<u8>(bit);
        bit_index++;

        // Log detailed info for first 3 bytes (24 bits = first LED's RGB) - disabled by default
#if FASTLED_RX_LOG_ENABLED
        if (bytes_decoded < 3) {
            u32 high_ns = ticksToNs(rmt_symbols[i].duration0, ns_per_tick);
            u32 low_ns = ticksToNs(rmt_symbols[i].duration1, ns_per_tick);
            FL_LOG_RX("Bit[byte=" << bytes_decoded << ", bit=" << (bit_index-1) << "]: value=" << bit
                   << " (symbol " << i << ": high=" << high_ns << "ns, low=" << low_ns << "ns) current_byte=0x"
                   << fl::hex << static_cast<int>(current_byte) << fl::dec);
        }
#endif

        // Byte complete?
        if (bit_index == 8) {
            // Check buffer space
            if (bytes_decoded < bytes_out.size()) {
                bytes_out[bytes_decoded] = current_byte;
                bytes_decoded++;

                // Log completed byte for first 3 bytes - disabled by default
#if FASTLED_RX_LOG_ENABLED
                if (bytes_decoded <= 3) {
                    FL_LOG_RX("Byte[" << (bytes_decoded-1) << "] completed: 0x" << fl::hex << static_cast<int>(current_byte) << fl::dec);
                }
#endif
            } else {
                // Buffer full, stop decoding
                FL_WARN("decodeRmtSymbols: output buffer overflow at byte "
                        << bytes_decoded);
                buffer_overflow = true;
                break;
            }
            current_byte = 0;
            bit_index = 0;
        }
    }

    // Flush partial byte if we reached end of symbols without reset
    if (bit_index != 0 && !buffer_overflow) {
        FL_LOG_RX("decodeRmtSymbols: partial byte at end (bit_index="
                << bit_index << "), flushing");
        // Shift remaining bits to MSB position
        current_byte <<= (8 - bit_index);

        if (bytes_decoded < bytes_out.size()) {
            bytes_out[bytes_decoded] = current_byte;
            bytes_decoded++;
        } else {
            buffer_overflow = true;
        }
    }

    size_t symbols_processed = symbols.size() - start_index;
    FL_LOG_RX(
        "decodeRmtSymbols: decoded "
        << bytes_decoded << " bytes from " << symbols_processed << " symbols, "
        << error_count << " errors ("
        << (symbols_processed > 0 ? (100 * error_count / symbols_processed) : 0)
        << "%), " << reset_pulse_count << " reset pulses");

    // Determine error type and return Result
    if (buffer_overflow) {
        FL_WARN("decodeRmtSymbols: buffer overflow - output buffer too small");
        return fl::result<u32, DecodeError>::failure(
            DecodeError::BUFFER_OVERFLOW);
    }

    if (error_count >= (symbols.size() / 10)) {
        FL_WARN("decodeRmtSymbols: high error rate: "
                << error_count << "/" << symbols.size() << " symbols ("
                << (100 * error_count / symbols.size()) << "%)");
        return fl::result<u32, DecodeError>::failure(
            DecodeError::HIGH_ERROR_RATE);
    }

    return fl::result<u32, DecodeError>::success(bytes_decoded);
}

/**
 * @brief Decode RMT symbols to bytes using output iterator (template wrapper)
 * Internal implementation - not exposed in public header
 */
template <typename OutputIteratorUint8>
fl::result<u32, DecodeError>
decodeRmtSymbols(const ChipsetTiming4Phase &timing, u32 resolution_hz,
                 fl::span<const RmtSymbol> symbols, OutputIteratorUint8 out,
                 bool start_low = true) {
    // Chunk size for decoding
    constexpr size_t MAX_CHUNK_SIZE = 256;

    fl::array<u8, MAX_CHUNK_SIZE> chunk_buffer;
    fl::span<u8> chunk_span(chunk_buffer.data(), MAX_CHUNK_SIZE);

    // Call span-based decode implementation with edge detection
    auto result =
        decodeRmtSymbols(timing, resolution_hz, symbols, chunk_span, start_low);
    if (!result.ok()) {
        return fl::result<u32, DecodeError>::failure(result.error());
    }

    u32 bytes_decoded = result.value();

    // Copy decoded bytes to output iterator
    for (u32 i = 0; i < bytes_decoded; i++) {
        *out++ = chunk_buffer[i];
    }

    return fl::result<u32, DecodeError>::success(bytes_decoded);
}

} // anonymous namespace

// ============================================================================
// RMT RX Channel Implementation
// ============================================================================

/**
 * @brief Implementation of RMT RX Channel
 *
 * All methods are defined inline to avoid IRAM attribute warnings.
 * IRAM_ATTR must be specified only once (on definition), not separately on
 * declaration.
 */
class RmtRxChannelImpl : public RmtRxChannel {
  public:
    RmtRxChannelImpl(int pin) FL_NOEXCEPT
        : mChannel(nullptr), mPin(static_cast<gpio_num_t>(pin)),
          mResolutionHz(0), mBufferSize(0), mReceiveDone(false),
          mSymbolsReceived(0), mSignalRangeMinNs(100),
          mSignalRangeMaxNs(
              4000000) // 40us - Allow gaps between PARLIO streaming chunks
          ,
          mSkipCounter(0), mStartLow(true), mIoLoopBack(false), mUseDma(false),
          mDmaAllocated(false), mDmaCapableBuffer(nullptr),
          mDmaCapableBufferSize(0), mInternalBuffer(),
          mAccumulationBuffer(), mAccumulationOffset(0), mCallbackCount(0),
          mMemoryRegistered(false), mMemoryChannelId(0) {
        FL_LOG_RX("RmtRxChannel constructed with pin="
               << pin << " (other hardware params will be set in begin())");

        // CRITICAL: Register with RMT memory manager immediately in constructor
        // This ensures TX channels can detect RX presence BEFORE they are created
        // (TX channels are created on first show() call, which may happen before
        // RX begin() is called). On ESP32-S3, TX DMA must be disabled when RX is
        // active due to shared DMA channel.
        //
        // We use a minimum allocation (64 symbols) here just to mark RX as active.
        // The actual allocation size may be adjusted in begin() if needed.
        auto &memMgr = RmtMemoryManager::instance();
        mMemoryChannelId = static_cast<u8>(128 + (mPin & 0x7F));
        constexpr u32 kMinRxSymbols = 64;  // Minimum RX buffer size
        // Status-code variant (#2856 item 3.5) — call site only needs success/fail.
        size_t alloc_words = 0;
        if (memMgr.tryAllocateRx(mMemoryChannelId, kMinRxSymbols, false, alloc_words)) {
            mMemoryRegistered = true;
            FL_LOG_RX("RMT RX pre-registered with memory manager in constructor (channel_id="
                      << static_cast<int>(mMemoryChannelId) << ")");
        } else {
            FL_WARN("RMT RX failed to pre-register with memory manager (channel_id="
                    << static_cast<int>(mMemoryChannelId) << ")");
        }
    }

    ~RmtRxChannelImpl() override {
        if (mChannel) {
            FL_LOG_RX("Deleting RMT RX channel");
            // Disable channel before deletion (required by ESP-IDF)
            // Channel must be in "init" state (disabled) before
            // rmt_del_channel()
            rmt_disable(mChannel);
            rmt_del_channel(mChannel);
            mChannel = nullptr;
        }

        // Release the shared DMA slot if this channel was holding it
        if (mDmaAllocated) {
            auto &memMgr = RmtMemoryManager::instance();
            memMgr.freeDMA(mMemoryChannelId, false);
            mDmaAllocated = false;
        }

        // Free the DRAM-backed DMA capture buffer
        if (mDmaCapableBuffer) {
            heap_caps_free(mDmaCapableBuffer);
            mDmaCapableBuffer = nullptr;
            mDmaCapableBufferSize = 0;
        }

        // Unregister from RMT memory manager to release resources for TX channels
        if (mMemoryRegistered) {
            auto &memMgr = RmtMemoryManager::instance();
            memMgr.free(mMemoryChannelId, false);  // false = RX channel
            FL_LOG_RX("RMT RX channel unregistered from memory manager (channel_id="
                      << static_cast<int>(mMemoryChannelId) << ")");
            mMemoryRegistered = false;
        }
    }

    bool begin(const RxConfig &config) FL_NOEXCEPT override {
        // If the DMA setting changed on an already-created channel, we have to
        // tear it down — ESP-IDF bakes `flags.with_dma` into the channel at
        // rmt_new_rx_channel() time, so toggling on a later begin() is a no-op
        // unless we rebuild. Also release the shared DMA slot and any DRAM
        // capture buffer so first-time init below allocates cleanly.
        if (mChannel && config.use_dma != mUseDma) {
            FL_WARN("[RMT RX] use_dma changed ("
                    << (mUseDma ? "true" : "false") << " -> "
                    << (config.use_dma ? "true" : "false")
                    << ") - rebuilding channel");
            rmt_disable(mChannel);
            rmt_del_channel(mChannel);
            mChannel = nullptr;
            if (mDmaAllocated) {
                auto &memMgr = RmtMemoryManager::instance();
                memMgr.freeDMA(mMemoryChannelId, false);
                mDmaAllocated = false;
            }
            if (mMemoryRegistered) {
                auto &memMgr = RmtMemoryManager::instance();
                memMgr.free(mMemoryChannelId, false);
                mMemoryRegistered = false;
            }
            if (mDmaCapableBuffer) {
                heap_caps_free(mDmaCapableBuffer);
                mDmaCapableBuffer = nullptr;
                mDmaCapableBufferSize = 0;
            }
        }

        // Validate and extract hardware parameters on first call
        if (!mChannel) {
            // First-time initialization - extract hardware parameters from
            // config
            if (config.buffer_size == 0) {
                FL_WARN(
                    "RX begin: Invalid buffer_size in config (buffer_size=0)");
                return false;
            }

            mBufferSize = config.buffer_size;
            mResolutionHz = config.hz.has_value() ? config.hz.value()
                                                  : 40000000; // Default: 40MHz

            FL_LOG_RX("RX first-time init: pin="
                   << static_cast<int>(mPin) << ", buffer_size=" << mBufferSize
                   << ", resolution_hz=" << mResolutionHz);
        }

        // Store configuration parameters
        mSignalRangeMinNs = config.signal_range_min_ns;
        mSignalRangeMaxNs = config.signal_range_max_ns;
        mSkipCounter = config.skip_signals;
        mStartLow = config.start_low;
        mIoLoopBack = config.io_loop_back;
        mUseDma = config.use_dma;

        FL_LOG_RX("RX begin: signal_range_min="
               << mSignalRangeMinNs
               << "ns, signal_range_max=" << mSignalRangeMaxNs << "ns"
               << ", skip_signals=" << config.skip_signals
               << ", start_low=" << (mStartLow ? "true" : "false"));

        // If already initialized, just re-arm the receiver for a new capture
        if (mChannel) {
            // Disable channel to reset to "init" state
            esp_err_t err = rmt_disable(mChannel);
            if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
                return false;
            }

            // Clear receive state (resets mSkipCounter to skip_signals_)
            clear();

            // Enable channel (transition "init" → "enabled")
            if (!enable()) {
                return false;
            }

            // In non-DMA mode, skip_signals must be drained via a synchronous
            // pre-capture loop because the receiver only fires one callback per
            // rmt_receive() in non-DMA mode. In DMA streaming mode the ISR
            // filters skip chunks inline as they arrive, so no pre-capture
            // drain is needed (and would deadlock waiting for a TX that hasn't
            // started yet).
            if (!mUseDma) {
                if (!handleSkipPhase()) {
                    return false;
                }
            }

            // Allocate buffer and arm receiver for actual capture
            if (!allocateAndArm()) {
                return false;
            }

            return true;
        }

        // First-time initialization
        // Clear receive state
        clear();

        // Configure RX channel
        rmt_rx_channel_config_t rx_config = {};
        rx_config.gpio_num = mPin;
        rx_config.clk_src = RMT_CLK_SRC_DEFAULT;
        rx_config.resolution_hz = mResolutionHz;
        // mem_block_symbols:
        //   Non-DMA: hardware RMT memory block, small (~64 symbols hard cap).
        //   DMA:     sizes ESP-IDF's DMA descriptor chain. Per rmt_rx.c:210-211:
        //     num_dma_nodes = max(2, mem_block_symbols * 4 / 4095 + 1)
        //   and the per-rmt_receive() user-buffer cap is num_dma_nodes * 4092
        //   bytes (=1023 symbols/node). So to pass a 14000-symbol user buffer
        //   (= 56 KB) we need ≥14 DMA nodes, which means mem_block_symbols ≥
        //   14336. Set 14336 to cover ~595 WS2812B LEDs in a single rmt_receive.
        //   Memory cost: 14336 × 4 = 56 KB internal-RAM DMA buffer — ESP32-S3
        //   has ~320 KB so ~18%. See issue #2254.
        rx_config.mem_block_symbols = mUseDma ? 14336 : 64;
        // Interrupt priority level 3 (maximum supported by ESP-IDF RMT driver
        // API) Note: Both RISC-V and Xtensa platforms are limited to level 3 by
        // driver validation RISC-V hardware supports 1-7, but ESP-IDF
        // rmt_new_rx_channel() rejects >3 Testing on ESP32-C6 confirmed level 4
        // fails with ESP_ERR_INVALID_ARG
        rx_config.intr_priority = 3;

        // Additional flags
        rx_config.flags.invert_in = false; // No signal inversion
        // DMA streaming mode: extends capture past the ~4096-symbol mem-block
        // cap by firing the ISR on every ping-pong fill. Must be baked in at
        // rmt_new_rx_channel() time; toggling later requires rebuilding.
        rx_config.flags.with_dma = mUseDma ? 1 : 0;
        if (mUseDma) {
            FL_WARN("[RMT RX] DMA streaming mode requested on GPIO "
                    << static_cast<int>(mPin)
                    << " (mem_block_symbols=" << rx_config.mem_block_symbols << ")");
        }
        // Internal loopback configuration:
        // When io_loop_back=true, RX receives from TX output internally (same GPIO).
        // This is REQUIRED for RMT TX → RMT RX validation on ESP32-S3 because
        // ESP32-S3 has a hardware limitation where TX GPIO output stops when RX
        // is actively receiving on a different GPIO. With loopback enabled,
        // TX output is routed both to the GPIO AND internally to RX.
#if !ESP_IDF_VERSION_6_OR_HIGHER
        rx_config.flags.io_loop_back = mIoLoopBack ? 1 : 0;
#endif
        if (mIoLoopBack) {
            FL_WARN("[RMT RX] Internal loopback enabled (io_loop_back=1) on GPIO " << static_cast<int>(mPin));
        }

        // Note: RX channel is already registered with RmtMemoryManager in constructor
        // to ensure TX channels can detect RX presence before they are created.
        // The registration happens early so that TX channels disable DMA when RX is active
        // (ESP32-S3 has shared DMA channel between TX and RX).
        if (!mMemoryRegistered) {
            FL_WARN("RMT RX was not pre-registered in constructor - registering now");
            auto &memMgr = RmtMemoryManager::instance();
            mMemoryChannelId = static_cast<u8>(128 + (mPin & 0x7F));
            // Pass use_dma=true when DMA mode is on so the memory manager
            // bypasses the on-chip RX pool (DMA uses DRAM). Without this,
            // larger mem_block_symbols values (e.g., 1024 for DMA) would
            // exceed ESP32-S3's 192-word dedicated RX pool and fail.
            // Status-code variant (#2856 item 3.5).
            size_t rx_alloc_words = 0;
            if (!memMgr.tryAllocateRx(mMemoryChannelId, rx_config.mem_block_symbols,
                                       mUseDma, rx_alloc_words)) {
                FL_WARN("RMT RX memory allocation failed for channel "
                        << static_cast<int>(mMemoryChannelId));
                return false;
            }
            mMemoryRegistered = true;
        }

        // ESP32-S3 has a single shared DMA slot between RMT TX and RX. Acquire
        // it here when DMA mode is requested. If the slot is already held by a
        // TX channel, fall back to non-DMA mode automatically so capture still
        // works (just with the shorter 4096-symbol cap).
        if (mUseDma) {
            auto &memMgr = RmtMemoryManager::instance();
            if (!memMgr.allocateDMA(mMemoryChannelId, false)) {
                FL_WARN("[RMT RX] Shared DMA slot unavailable — falling back to non-DMA mode");
                mUseDma = false;
                rx_config.flags.with_dma = 0;
                rx_config.mem_block_symbols = 64;
            } else {
                mDmaAllocated = true;
            }
        }

        // Create RX channel
        esp_err_t err = rmt_new_rx_channel(&rx_config, &mChannel);
        if (err != ESP_OK) {
            FL_WARN("Failed to create RX channel: " << static_cast<int>(err)
                    << " (" << esp_err_to_name(err) << ")");
            // Unregister from memory manager on failure
            if (mMemoryRegistered) {
                auto &memMgr = RmtMemoryManager::instance();
                memMgr.free(mMemoryChannelId, false);
                mMemoryRegistered = false;
            }
            return false;
        }

        FL_LOG_RX("RX channel created successfully");

        // Register ISR callback
        rmt_rx_event_callbacks_t callbacks = {};
        callbacks.on_recv_done = rxDoneCallback;

        err = rmt_rx_register_event_callbacks(mChannel, &callbacks, this);
        if (err != ESP_OK) {
            FL_WARN(
                "Failed to register RX callbacks: " << static_cast<int>(err));
            rmt_del_channel(mChannel);
            mChannel = nullptr;
            return false;
        }

        FL_LOG_RX("RX callbacks registered successfully");

        // Enable RX channel - rmt_receive() requires channel to be in "enabled"
        // state The channel is created in "init" state by rmt_new_rx_channel()
        // We must call rmt_enable() before calling rmt_receive()
        if (!enable()) {
            FL_WARN("Failed to enable RX channel");
            rmt_del_channel(mChannel);
            mChannel = nullptr;
            return false;
        }

        // Non-DMA mode: drain skip_signals synchronously via a pre-capture
        // discard receive. DMA streaming mode does the skip filter inline in
        // the ISR so no pre-capture drain is required (see rxDoneCallback).
        if (!mUseDma) {
            if (!handleSkipPhase()) {
                FL_WARN("Failed to handle skip phase in begin()");
                rmt_disable(mChannel);
                rmt_del_channel(mChannel);
                mChannel = nullptr;
                return false;
            }
        }

        // Allocate buffer and arm receiver for actual capture
        if (!allocateAndArm()) {
            FL_WARN("Failed to arm receiver in begin()");
            rmt_disable(mChannel);
            rmt_del_channel(mChannel);
            mChannel = nullptr;
            // Release the DMA slot we acquired earlier — otherwise the slot
            // leaks and subsequent begin() retries report "Shared DMA slot
            // unavailable" until the RxDevice is destroyed.
            if (mDmaAllocated) {
                auto &memMgr = RmtMemoryManager::instance();
                memMgr.freeDMA(mMemoryChannelId, false);
                mDmaAllocated = false;
            }
            if (mMemoryRegistered) {
                auto &memMgr = RmtMemoryManager::instance();
                memMgr.free(mMemoryChannelId, false);
                mMemoryRegistered = false;
            }
            return false;
        }

        FL_LOG_RX("RX receiver armed and ready");
        return true;
    }

    bool finished() const FL_NOEXCEPT override { return mReceiveDone; }

    RxWaitResult wait(u32 timeout_ms) FL_NOEXCEPT override {
        if (!mChannel) {
            FL_WARN("wait(): channel not initialized");
            return RxWaitResult::TIMEOUT; // Treat as timeout
        }

        FL_LOG_RX("wait(): buffer_size=" << mBufferSize
                                      << ", timeout_ms=" << timeout_ms);

        // Only allocate and arm if not already receiving (begin() wasn't
        // called) Check if accumulation buffer is empty to determine if we need
        // to arm
        if (mAccumulationBuffer.empty()) {
            // Allocate buffer and arm receiver
            if (!allocateAndArm()) {
                FL_WARN("wait(): failed to allocate and arm");
                return RxWaitResult::TIMEOUT; // Treat as timeout
            }
        } else {
            FL_LOG_RX("wait(): receiver already armed, using existing buffer");
        }

        // Convert timeout to microseconds for comparison
        i64 timeout_us = static_cast<i64>(timeout_ms) * 1000;

        // Busy-wait with yield (using ESP-IDF native functions)
        i64 start_time_us = esp_timer_get_time();
        while (!finished()) {
            i64 elapsed_us = esp_timer_get_time() - start_time_us;

            // Check if buffer filled (success condition)
            if (mSymbolsReceived >= mBufferSize) {
                FL_LOG_RX("wait(): buffer filled (" << mSymbolsReceived << ")");
                return RxWaitResult::SUCCESS;
            }

            // Check timeout
            if (elapsed_us >= timeout_us) {
                FL_ERROR("RMT RX timeout after "
                        << elapsed_us << "us, received " << mSymbolsReceived
                        << " symbols (expected " << mBufferSize << ")");
                FL_ERROR("RMT RX: No data received from TX pin - check that PARLIO/SPI/RMT TX is transmitting");
                return RxWaitResult::TIMEOUT;
            }

            taskYIELD(); // Allow other tasks to run
        }

        // Receive completed naturally (spurious symbols already filtered in
        // ISR)
        FL_LOG_RX("wait(): receive done, count=" << mSymbolsReceived);
        // Visible (non-FL_DBG) telemetry: lets us see whether en_partial_rx
        // is actually firing multiple callbacks per transmission on long
        // streams. mCallbackCount == 1 on a long-stream capture indicates
        // ESP-IDF ended the receive after one fill (likely due to idle
        // timeout from signal_range_max_ns). See issue #2254.
        FL_WARN("[RMT RX] wait(): symbols=" << mSymbolsReceived
                                          << " callbacks=" << mCallbackCount
                                          << " use_dma="
                                          << (mUseDma ? "true" : "false"));
        return RxWaitResult::SUCCESS;
    }

    u32 getResolutionHz() const FL_NOEXCEPT override { return mResolutionHz; }

    fl::result<u32, DecodeError> decode(const ChipsetTiming4Phase &timing,
                                             fl::span<u8> out) FL_NOEXCEPT override {
        // Get received symbols (spurious symbols already filtered by wait())
        fl::span<const RmtSymbol> symbols = getReceivedSymbols();

        if (symbols.empty()) {
            return fl::result<u32, DecodeError>::failure(
                DecodeError::INVALID_ARGUMENT);
        }

        // Use the span-based decoder (spurious symbols already filtered
        // upstream in wait()) Pass start_low=true since buffer now starts at
        // first valid edge
        return decodeRmtSymbols(timing, mResolutionHz, symbols, out, true);
    }

    const char *name() const FL_NOEXCEPT override { return "RMT"; }

    int getPin() const FL_NOEXCEPT override { return static_cast<int>(mPin); }

    size_t getRawEdgeTimes(fl::span<EdgeTime> out, size_t offset = 0) FL_NOEXCEPT override {
        // Get received symbols (spurious symbols already filtered by wait())
        fl::span<const RmtSymbol> symbols = getReceivedSymbols();

        if (symbols.empty() || out.empty()) {
            return 0;
        }

        // Calculate nanoseconds per tick for conversion
        u32 ns_per_tick = 1000000000UL / mResolutionHz;

        // Each RMT symbol produces 2 EdgeTime entries (duration0/level0,
        // duration1/level1) Cast RmtSymbol to rmt_symbol_word_t to access
        // bitfields
        const auto *rmt_symbols =
            fl::bit_cast_ptr<const rmt_symbol_word_t>(symbols.data());

        // First pass: Count total edges
        size_t total_edges = 0;
        for (size_t i = 0; i < symbols.size(); i++) {
            const auto &sym = rmt_symbols[i];
            if (sym.duration0 > 0) total_edges++;
            if (sym.duration1 > 0) total_edges++;
        }

        // Calculate range to extract
        size_t start_edge = offset;
        size_t end_edge = offset + out.size();  // span size = max count

        if (start_edge >= total_edges) return 0;
        if (end_edge > total_edges) end_edge = total_edges;

        // Second pass: Extract edges in requested range
        size_t current_edge = 0;
        size_t write_index = 0;

        for (size_t i = 0; i < symbols.size() && write_index < out.size(); i++) {
            const auto &sym = rmt_symbols[i];

            // First edge: duration0 with level0
            if (sym.duration0 > 0) {
                if (current_edge >= start_edge && current_edge < end_edge) {
                    out[write_index] = EdgeTime(
                        sym.level0 != 0, ticksToNs(sym.duration0, ns_per_tick));
                    write_index++;
                }
                current_edge++;
            }

            // Second edge: duration1 with level1
            if (sym.duration1 > 0) {
                if (current_edge >= start_edge && current_edge < end_edge) {
                    out[write_index] = EdgeTime(
                        sym.level1 != 0, ticksToNs(sym.duration1, ns_per_tick));
                    write_index++;
                }
                current_edge++;
            }

            // Early exit if we've written all requested edges
            if (current_edge >= end_edge) break;
        }

        return write_index;
    }

    bool injectEdges(fl::span<const EdgeTime> edges) FL_NOEXCEPT override {
        if (edges.empty()) {
            FL_WARN("injectEdges(): empty edges span");
            return false;
        }

        // Check if edges count is even (must have pairs for RMT symbols)
        if (edges.size() % 2 != 0) {
            FL_WARN("injectEdges(): edge count must be even (got "
                    << edges.size() << ")");
            return false;
        }

        // Calculate number of symbols needed
        size_t symbol_count = edges.size() / 2;

        // Allocate accumulation buffer if needed
        if (mAccumulationBuffer.empty()) {
            mAccumulationBuffer.clear();
            mAccumulationBuffer.reserve(symbol_count);
            for (size_t i = 0; i < symbol_count; i++) {
                mAccumulationBuffer.push_back(0);
            }
        } else if (mAccumulationBuffer.size() < symbol_count) {
            FL_WARN("injectEdges(): accumulation buffer too small (need "
                    << symbol_count << ", have " << mAccumulationBuffer.size()
                    << ")");
            return false;
        }

        // Calculate nanoseconds per tick for conversion (ns → ticks)
        // Example: 40MHz = 40,000,000 Hz → 1,000,000,000 / 40,000,000 = 25ns
        // per tick
        u32 ns_per_tick = 1000000000UL / mResolutionHz;

        FL_LOG_RX("injectEdges(): converting "
               << edges.size() << " edges to " << symbol_count
               << " RMT symbols (resolution=" << mResolutionHz
               << "Hz, ns_per_tick=" << ns_per_tick << ")");

        // Convert EdgeTime pairs to RMT symbols
        auto *rmt_symbols =
            fl::bit_cast_ptr<rmt_symbol_word_t>(mAccumulationBuffer.data());

        for (size_t i = 0; i < symbol_count; i++) {
            const EdgeTime &edge0 = edges[i * 2];     // First edge (duration0)
            const EdgeTime &edge1 = edges[i * 2 + 1]; // Second edge (duration1)

            // Convert nanoseconds to ticks
            u32 duration0_ticks =
                (edge0.ns + ns_per_tick / 2) / ns_per_tick; // Round to nearest
            u32 duration1_ticks =
                (edge1.ns + ns_per_tick / 2) / ns_per_tick;

            // Clamp to RMT symbol limits (15 bits = 0-32767)
            if (duration0_ticks > 32767)
                duration0_ticks = 32767;
            if (duration1_ticks > 32767)
                duration1_ticks = 32767;

            // Build RMT symbol
            rmt_symbols[i].duration0 = duration0_ticks;
            rmt_symbols[i].level0 = edge0.high ? 1 : 0;
            rmt_symbols[i].duration1 = duration1_ticks;
            rmt_symbols[i].level1 = edge1.high ? 1 : 0;

            FL_LOG_RX("  Symbol["
                   << i << "]: duration0=" << duration0_ticks << " ("
                   << edge0.ns << "ns), level0=" << (int)rmt_symbols[i].level0
                   << ", duration1=" << duration1_ticks << " (" << edge1.ns
                   << "ns), level1=" << (int)rmt_symbols[i].level1);
        }

        // Update state
        mSymbolsReceived = symbol_count;
        mReceiveDone = true;

        FL_LOG_RX("injectEdges(): injected " << symbol_count
                                          << " symbols successfully");
        return true;
    }

  private:
    /**
     * @brief Handle skip phase by discarding symbols until mSkipCounter reaches
     * 0
     * @return true on success, false on failure
     *
     * Uses a small discard buffer to receive and discard symbols without
     * allocating the full buffer. This ensures we don't buffer unwanted
     * symbols. Only allocates memory temporarily during the skip phase.
     */
    bool handleSkipPhase() FL_NOEXCEPT {
        if (mSkipCounter == 0) {
            FL_LOG_RX("No symbols to skip, skip phase complete");
            return true;
        }

        FL_LOG_RX("Entering skip phase: skipping " << mSkipCounter << " symbols");

        // Use a small discard buffer (64 symbols = 256 bytes)
        constexpr size_t DISCARD_BUFFER_SIZE = 64;
        fl::vector<RmtSymbol> discard_buffer;
        discard_buffer.reserve(DISCARD_BUFFER_SIZE);
        for (size_t i = 0; i < DISCARD_BUFFER_SIZE; i++) {
            discard_buffer.push_back(0);
        }

        // Skip symbols in chunks until mSkipCounter reaches 0
        while (mSkipCounter > 0) {
            size_t chunk_size = (mSkipCounter < DISCARD_BUFFER_SIZE)
                                    ? mSkipCounter
                                    : DISCARD_BUFFER_SIZE;

            FL_LOG_RX("Skip phase: receiving chunk of "
                   << chunk_size << " symbols (remaining: " << mSkipCounter
                   << ")");

            // DO NOT enable channel here - rmt_receive() requires channel to be
            // in "init" (disabled) state startReceive() calls rmt_receive()
            // which internally calls rmt_rx_enable() and rmt_rx_enable()
            // expects the channel to be in "init" state, not "enabled" state

            // Start receive with discard buffer (rmt_receive will enable the RX
            // hardware internally)
            if (!startReceive(discard_buffer.data(), chunk_size)) {
                FL_WARN("handleSkipPhase(): failed to start receive");
                return false;
            }

            // Wait for receive to complete (with timeout)
            constexpr u32 SKIP_TIMEOUT_MS =
                5000; // 5 second timeout per chunk
            i64 start_time_us = esp_timer_get_time();
            i64 timeout_us = SKIP_TIMEOUT_MS * 1000;

            while (!mReceiveDone) {
                i64 elapsed_us = esp_timer_get_time() - start_time_us;
                if (elapsed_us >= timeout_us) {
                    FL_WARN("handleSkipPhase(): timeout waiting for symbols");
                    return false;
                }
                taskYIELD();
            }

            // Check if ISR properly decremented mSkipCounter
            // (ISR callback handles decrementing mSkipCounter)
            FL_LOG_RX("Skip phase chunk complete, mSkipCounter now: "
                   << mSkipCounter);

            // Disable channel to reset to "init" state for next iteration
            // After rmt_receive() completes, channel is in "enabled" state
            // We need to disable it before calling rmt_receive() again
            if (mSkipCounter > 0) { // Only disable if more iterations needed
                esp_err_t err = rmt_disable(mChannel);
                if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
                    FL_WARN("handleSkipPhase(): failed to disable channel for "
                            "next iteration: "
                            << static_cast<int>(err));
                    return false;
                }
                FL_LOG_RX("Skip phase: channel disabled for next iteration");
            }

            // Reset mReceiveDone for next iteration
            mReceiveDone = false;
        }

        FL_LOG_RX("Skip phase complete");

        // After skip phase completes, channel may be in "enabled" state from
        // the last rmt_receive() We need to ensure it's in "init" (disabled)
        // state before calling allocateAndArm() If skip_signals=0, channel is
        // already in "init" state, so rmt_disable() may return
        // ESP_ERR_INVALID_STATE
        if (mChannel) {
            esp_err_t err = rmt_disable(mChannel);
            if (err == ESP_OK) {
                FL_LOG_RX(
                    "Skip phase: channel disabled, ready for allocateAndArm()");
            } else if (err == ESP_ERR_INVALID_STATE) {
                FL_LOG_RX("Skip phase: channel already in init state "
                       "(skip_signals=0 case)");
            } else {
                FL_WARN("handleSkipPhase(): failed to disable channel after "
                        "skip phase: "
                        << static_cast<int>(err));
                return false;
            }
        }

        return true;
    }

    /**
     * @brief Allocate buffer and arm receiver (internal helper)
     * @return true on success, false on failure
     *
     * Common logic for allocating internal buffer and starting receive
     * operation. REQUIRES: Channel must be in "enabled" state before calling.
     *
     * Dual-Buffer Architecture:
     * - DMA buffer (mInternalBuffer): Small buffer (4096 symbols) for hardware
     * RX
     * - Accumulation buffer (mAccumulationBuffer): Full-size buffer
     * (user-requested) for complete stream
     * - ISR callback copies from DMA buffer to accumulation buffer on each
     * partial RX callback
     */
    bool allocateAndArm() FL_NOEXCEPT {
        // Per-receive buffer that ESP-IDF writes into. In non-DMA mode this is
        // sized for a single mem-block fill; in DMA mode ESP-IDF requires the
        // user buffer to be ≤ mem_block_symbols (the DRAM ping-pong size),
        // and the ISR streams from it into the accumulation buffer on every
        // partial-rx callback. The DMA variant MUST live in MALLOC_CAP_DMA |
        // MALLOC_CAP_INTERNAL memory — a plain fl::vector can land in PSRAM on
        // ESP32-S3, which ESP-IDF rejects with "user buffer not in the internal
        // RAM". See issue #2254.
        constexpr size_t NONDMA_BUFFER_SIZE = 4096;
        // DMA user buffer sized to fit within num_dma_nodes × 4092 bytes. With
        // mem_block_symbols=14336 we get 14 DMA nodes = 57288 bytes = 14322
        // symbols capacity. Pass 14000 symbols (= 56000 bytes) with margin.
        // Covers 583 WS2812B LEDs in a single rmt_receive() — no need for
        // ISR re-submission on typical long strips. Partial-rx ISR still fires
        // every ~1023-symbol DMA node fill, streaming into the accumulation
        // buffer via rxDoneCallback.
        constexpr size_t DMA_BUFFER_SIZE = 14000;
        const size_t hw_buffer_size = mUseDma ? DMA_BUFFER_SIZE : NONDMA_BUFFER_SIZE;

        if (mUseDma) {
            if (mDmaCapableBuffer && mDmaCapableBufferSize < hw_buffer_size) {
                heap_caps_free(mDmaCapableBuffer);
                mDmaCapableBuffer = nullptr;
                mDmaCapableBufferSize = 0;
            }
            if (!mDmaCapableBuffer) {
                size_t bytes = hw_buffer_size * sizeof(RmtSymbol);
                mDmaCapableBuffer = static_cast<RmtSymbol *>(
                    heap_caps_malloc(bytes, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL));
                if (!mDmaCapableBuffer) {
                    FL_WARN("allocateAndArm(): heap_caps_malloc("
                            << bytes << ", DMA|INTERNAL) failed");
                    return false;
                }
                mDmaCapableBufferSize = hw_buffer_size;
            }
            for (size_t i = 0; i < hw_buffer_size; i++) {
                mDmaCapableBuffer[i] = 0;
            }
        } else {
            mInternalBuffer.clear();
            mInternalBuffer.reserve(hw_buffer_size);
            for (size_t i = 0; i < hw_buffer_size; i++) {
                mInternalBuffer.push_back(0);
            }
        }

        // Allocate accumulation buffer (full user-requested size)
        // On memory-constrained devices (e.g. ESP32-C6 with 320KB SRAM),
        // the full buffer may not fit. Cap at 25% of free heap to leave
        // room for PARLIO DMA buffers, JSON responses, and other allocations.
        mAccumulationBuffer.clear();
        size_t target_size = mBufferSize;
        size_t free_heap = esp_get_free_heap_size();
        size_t max_alloc = free_heap / 4;  // Use at most 25% of free heap
        size_t target_bytes = target_size * sizeof(RmtSymbol);
        if (target_bytes > max_alloc && max_alloc > hw_buffer_size * sizeof(RmtSymbol)) {
            target_size = max_alloc / sizeof(RmtSymbol);
        }
        mAccumulationBuffer.reserve(target_size);
        // Fall back to hw buffer size if reserve fails
        if (mAccumulationBuffer.capacity() < hw_buffer_size) {
            mAccumulationBuffer.reserve(hw_buffer_size);
        }
        size_t effective_size = mAccumulationBuffer.capacity();
        if (effective_size < mBufferSize) {
            FL_WARN("allocateAndArm(): accumulation buffer reduced from "
                    << mBufferSize << " to " << effective_size
                    << " symbols (memory constrained)");
        }
        for (size_t i = mAccumulationBuffer.size(); i < effective_size; i++) {
            mAccumulationBuffer.push_back(0);
        }

        FL_LOG_RX("allocateAndArm(): hw buffer="
               << hw_buffer_size << " symbols (use_dma="
               << (mUseDma ? "true" : "false") << "), accumulation buffer="
               << mBufferSize << " symbols");

        // Start receive operation (pass the hardware buffer, not accumulation).
        // In DMA streaming mode the ISR will fire repeatedly, copying each fill
        // into the accumulation buffer before the next DMA chunk overwrites.
        RmtSymbol *hw_buffer = mUseDma ? mDmaCapableBuffer
                                        : mInternalBuffer.data();
        if (!startReceive(hw_buffer, hw_buffer_size)) {
            FL_WARN("allocateAndArm(): failed to start receive");
            return false;
        }

        return true;
    }

    /**
     * @brief Enable RMT RX channel (internal method)
     * @return true on success, false on failure
     *
     * Enables the RMT RX channel, transitioning from "init" to "enabled" state.
     * The channel must be in "init" (disabled) state before calling this
     * method.
     *
     * After calling enable(), the channel is ready to call rmt_receive().
     * After a receive operation completes, the channel remains in "enabled"
     * state but must be disabled before calling rmt_receive() again.
     *
     * State transitions:
     *   - rmt_new_rx_channel() → "init" state
     *   - rmt_enable() → "enabled" state
     *   - rmt_receive() → requires "enabled" state
     *   - After receive completes → remains "enabled" but must disable before
     * next receive
     *   - rmt_disable() → "init" state (ready for next enable/receive cycle)
     */
    bool enable() FL_NOEXCEPT {
        if (!mChannel) {
            FL_WARN("enable(): RX channel not initialized");
            return false;
        }

        // Enable channel (transition "init" → "enabled")
        esp_err_t err = rmt_enable(mChannel);

        // ESP_OK: Successfully enabled
        if (err == ESP_OK) {
            FL_LOG_RX("RX channel enabled");
            return true;
        } else {
            FL_WARN("Failed to enable RX channel: "
                    << esp_err_to_name(err)
                    << " (code: " << static_cast<int>(err) << ")");
            return false;
        }
    }

    /**
     * @brief Get received symbols as a span (internal method)
     * @return Span of const RmtSymbol containing received data from
     * accumulation buffer
     *
     * Returns data from the accumulation buffer (not DMA buffer).
     * The accumulation buffer contains the complete data stream assembled from
     * multiple partial RX callbacks.
     */
    fl::span<const RmtSymbol> getReceivedSymbols() const FL_NOEXCEPT {
        if (mAccumulationBuffer.empty()) {
            return fl::span<const RmtSymbol>();
        }
        return fl::span<const RmtSymbol>(mAccumulationBuffer.data(),
                                         mSymbolsReceived);
    }

    /**
     * @brief Clear receive state (internal method)
     */
    void clear() FL_NOEXCEPT {
        mReceiveDone = false;
        mSymbolsReceived = 0;
        mAccumulationOffset = 0;
        // mSkipCounter is set in begin(), not here
        FL_LOG_RX("RX state cleared");
    }

    /**
     * @brief Internal method to start receiving RMT symbols
     * @param buffer Buffer to store received symbols (must remain valid until
     * done)
     * @param buffer_size Number of symbols buffer can hold
     * @return true if receive started, false on error
     *
     * IMPORTANT: This method is called with the DMA buffer, not the
     * accumulation buffer. The ISR callback will copy from DMA buffer →
     * accumulation buffer on each partial RX callback.
     */
    bool startReceive(RmtSymbol *buffer, size_t buffer_size) FL_NOEXCEPT {
        if (!mChannel) {
            FL_WARN("RX channel not initialized (call begin() first)");
            return false;
        }

        if (!buffer || buffer_size == 0) {
            FL_WARN("Invalid buffer parameters");
            return false;
        }

        // Reset state for new receive operation
        mReceiveDone = false;
        mSymbolsReceived = 0;
        mAccumulationOffset = 0; // Reset accumulation offset for new capture
        mCallbackCount = 0;      // Reset callback counter for debugging

        // Configure receive parameters (use values from begin())
        // Clamp signal_range_max_ns to hardware limit: each RMT symbol duration
        // field is 15 bits (max 32767 ticks). At the configured resolution,
        // max_ns = 32767 * (1e9 / resolution_hz).
        // ESP32-C6 at 40MHz: 32767 * 25 = 818675 ns (~819µs)
        u32 max_signal_range_ns = mSignalRangeMaxNs;
        if (mResolutionHz > 0) {
            u64 tick_ns = 1000000000ULL / mResolutionHz;
            u64 hw_max_ns = 32767ULL * tick_ns;
            if (max_signal_range_ns > hw_max_ns) {
                max_signal_range_ns = static_cast<u32>(hw_max_ns);
                FL_LOG_RX("Clamped signal_range_max_ns from " << mSignalRangeMaxNs
                       << " to " << max_signal_range_ns << " (hw limit at "
                       << mResolutionHz << "Hz)");
            }
        }
        rmt_receive_config_t rx_params = {};
        rx_params.signal_range_min_ns = mSignalRangeMinNs;
        rx_params.signal_range_max_ns = max_signal_range_ns;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
        rx_params.flags.en_partial_rx =
            true; // Enable partial reception for long data streams (>~250
                  // symbols, ESP-IDF 5.3+)
#endif

        // Cast RmtSymbol* to rmt_symbol_word_t* (safe due to static_assert
        // above)
        auto *rmt_buffer = reinterpret_cast<rmt_symbol_word_t *>(buffer); // ok reinterpret cast - ESP-IDF hardware API

        // Start receiving
        esp_err_t err =
            rmt_receive(mChannel, rmt_buffer,
                        buffer_size * sizeof(rmt_symbol_word_t), &rx_params);
        if (err != ESP_OK) {
            FL_WARN("Failed to start RX receive: " << static_cast<int>(err));
            return false;
        }

        FL_LOG_RX("RX receive started (DMA buffer size: " << buffer_size
                                                       << " symbols)");
        return true;
    }

    /**
     * @brief ISR callback for receive completion
     *
     * IRAM_ATTR specified on definition only (not declaration) to avoid
     * warnings.
     *
     * Skip logic: When mSkipCounter > 0, we're in skip phase (called from
     * handleSkipPhase). Decrement mSkipCounter and discard symbols. When
     * mSkipCounter == 0, we're in capture phase - store the received symbols
     * and filter spurious idle-state symbols.
     *
     * Dual-Buffer Architecture (ESP-IDF 5.3.0+):
     * With en_partial_rx enabled, this callback fires multiple times for long
     * streams. Each callback receives a new chunk of data in
     * data->received_symbols (DMA buffer). We must MANUALLY COPY from DMA
     * buffer to accumulation buffer before the next callback overwrites it.
     *
     * ESP-IDF < 5.3.0: Single-shot receive mode only (callback fires once,
     * buffer size limited).
     */
    static bool IRAM_ATTR rxDoneCallback(rmt_channel_handle_t channel,
                                         const rmt_rx_done_event_data_t *data,
                                         void *user_data) FL_NOEXCEPT {
        RmtRxChannelImpl *self = static_cast<RmtRxChannelImpl *>(user_data);
        if (!self) {
            return false;
        }

        self->mCallbackCount =
            self->mCallbackCount + 1; // Debug: Track callback invocations
        size_t received_count = data->num_symbols;
        const rmt_symbol_word_t *src = data->received_symbols;
        size_t src_offset = 0; // symbols consumed by in-stream skip

        // In-stream skip (DMA / partial-rx mode).
        // Old single-shot handleSkipPhase() used mReceiveDone to hand back
        // control after each 64-symbol drain. In DMA streaming mode the
        // receiver fires the ISR on every ping-pong fill, and we want to
        // filter skip chunks inline without terminating the receive op.
        //
        //   - Chunk entirely inside skip window → drop whole chunk, continue.
        //   - Chunk straddles the skip boundary → advance past the skipped
        //     prefix and copy the remainder into the accumulation buffer.
        //   - Chunk entirely past the skip window → normal accumulation.
        //
        // In non-DMA mode the handleSkipPhase() wrapper still owns the drain
        // loop (it calls rmt_receive() in a loop, each fill sets is_last).
        // Preserving the legacy "set mReceiveDone on skip" behavior there so
        // the host-side while-loop wakes up to submit the next chunk.
        if (self->mSkipCounter > 0) {
            if (self->mSkipCounter >= received_count) {
                // Entire chunk discarded
                self->mSkipCounter -= received_count;
                if (!self->mUseDma) {
                    // Legacy non-DMA path: hand control back to handleSkipPhase
                    self->mSymbolsReceived = 0;
                    self->mReceiveDone = true;
                    return false;
                }
                // DMA streaming: do not terminate; wait for next fill
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
                if (data->flags.is_last) {
                    self->mReceiveDone = true;
                }
#endif
                return false;
            }
            // Partial skip: skip window ends inside this chunk
            src_offset = self->mSkipCounter;
            received_count -= self->mSkipCounter;
            self->mSkipCounter = 0;
            if (!self->mUseDma) {
                // Legacy non-DMA path terminates skip phase here; caller will
                // re-arm for real capture.
                self->mSymbolsReceived = 0;
                self->mReceiveDone = true;
                return false;
            }
            // DMA streaming: fall through and accumulate the non-skipped tail
        }

        // Capture phase - manually copy from DMA buffer to accumulation buffer
        // data->received_symbols points to the DMA buffer which will be
        // OVERWRITTEN on next callback

        // Calculate how many symbols we can safely copy (prevent buffer
        // overflow)
        size_t available_space =
            self->mAccumulationBuffer.size() - self->mAccumulationOffset;
        size_t symbols_to_copy = (received_count < available_space)
                                     ? received_count
                                     : available_space;

        if (symbols_to_copy > 0) {
            // Copy from DMA buffer to accumulation buffer
            // Use simple inline loop to avoid IRAM literal references (ESP32-S2
            // IRAM size limit) This approach prevents "l32r: literal placed
            // after use" linker errors
            RmtSymbol *dest =
                self->mAccumulationBuffer.data() + self->mAccumulationOffset;
            const rmt_symbol_word_t *src_effective = src + src_offset;

            // Copy word-by-word (32-bit aligned, efficient)
            for (size_t i = 0; i < symbols_to_copy; i++) {
                dest[i] = reinterpret_cast<const RmtSymbol &>(src_effective[i]); // ok reinterpret cast - ISR IRAM context
            }

            // Update accumulation offset
            FL_DISABLE_WARNING_PUSH
            FL_DISABLE_WARNING_VOLATILE
            self->mAccumulationOffset += symbols_to_copy;
            FL_DISABLE_WARNING_POP
        }

        // Update total symbols received
        self->mSymbolsReceived = self->mAccumulationOffset;

        // Only signal completion when this is the final callback (is_last flag
        // from ESP-IDF) With en_partial_rx enabled, callback may be invoked
        // multiple times for long streams
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0)
        if (data->flags.is_last) {
            self->mReceiveDone = true;
        }
#else
        // ESP-IDF < 5.3.0: No partial RX support, callback fires only once
        self->mReceiveDone = true;
#endif

        // No higher-priority task awakened
        return false;
    }

    rmt_channel_handle_t mChannel; ///< RMT channel handle
    gpio_num_t mPin;               ///< GPIO pin for RX
    u32 mResolutionHz;        ///< Clock resolution in Hz
    size_t mBufferSize; ///< User-requested buffer size in symbols (accumulation
                        ///< buffer size)
    volatile bool mReceiveDone;       ///< Set by ISR when receive complete
    volatile size_t mSymbolsReceived; ///< Total symbols received across all
                                      ///< callbacks (set by ISR)
    u32 mSignalRangeMinNs;       ///< Minimum pulse width (noise filtering)
    u32 mSignalRangeMaxNs;       ///< Maximum pulse width (idle detection)
    u32
        mSkipCounter; ///< Runtime counter for skipping (decremented in ISR)
    bool mStartLow;   ///< Pin idle state: true=LOW (WS2812B), false=HIGH
                      ///< (inverted)
    bool mIoLoopBack; ///< Enable internal RMT loopback (TX→RX on same GPIO)
    bool mUseDma;     ///< DMA streaming mode (ESP-IDF 5.3+ en_partial_rx)
    bool mDmaAllocated; ///< True if we hold the shared DMA slot
    RmtSymbol *mDmaCapableBuffer; ///< DRAM-resident ping-pong buffer for DMA
    size_t mDmaCapableBufferSize; ///< Capacity in symbols
    fl::vector<RmtSymbol>
        mInternalBuffer; ///< DMA buffer for hardware RX (small, ≤4096 symbols)
    fl::vector<RmtSymbol>
        mAccumulationBuffer; ///< Accumulation buffer for full data stream
                             ///< (user-requested size)
    volatile size_t
        mAccumulationOffset; ///< Current write position in accumulation buffer
                             ///< (updated by ISR)
    volatile u32 mCallbackCount; ///< Debug: Count ISR callbacks (TEMP -
                                      ///< for testing en_partial_rx)

    // RMT Memory Manager coordination (prevents TX/RX conflicts on ESP32-S3)
    bool mMemoryRegistered;       ///< Whether this RX channel is registered with memory manager
    u8 mMemoryChannelId;     ///< Channel ID for memory manager tracking (pin + 128)
};

// Factory method implementation
fl::shared_ptr<RmtRxChannel> RmtRxChannel::create(int pin) FL_NOEXCEPT {
    return fl::make_shared<RmtRxChannelImpl>(pin);
}

} // namespace fl

#else // !FASTLED_RMT5

// RMT4 platforms (ESP32 with ESP-IDF < 5.0) - provide stub implementation
// RX functionality requires RMT5 driver (ESP-IDF 5.0+)
namespace fl {

fl::shared_ptr<RmtRxChannel> RmtRxChannel::create(int pin) FL_NOEXCEPT {
    (void)pin; // Suppress unused parameter warning
    FL_WARN("RmtRxChannel::create() requires RMT5 driver (ESP-IDF 5.0+). "
            "Returning nullptr.");
    return nullptr;
}

} // namespace fl

#endif // FASTLED_RMT5
#endif // FL_IS_ESP32
