// ----------------------------------------------------------------------------
// LDAS-Tools issue #251 reproducer / regression test
// https://git.ligo.org/computing/ldastools/LDAS_Tools/-/work_items/251
// ----------------------------------------------------------------------------
//
// What this program does
// ----------------------
// Opens a single .gwf file three different ways and prints, for each frame,
// the GPS start time and the per-frame duration (`dt`), plus the total time
// span computed as `(GTime(last) + dt(last)) - GTime(first)`. The three
// inspection paths exercise different layers of FrameCPP and a different
// subset of its public API:
//
//   PASS_A_CPP    C++ OOInterface
//                   - FrameCPP::IFrameFStream
//                   - reads the file's TOC via stream.GetTOC()
//                   - then loads each frame via stream.ReadFrameN(i) and
//                     queries FrameH::GetGTime()/GetDt() directly.
//
//   PASS_B_C_TOC  C-API via TOC (this mirrors LALFrame's call sequence
//                                exactly when LAL_FRAME_LIBRARY=FrameC)
//                   - FrameCFileOpen()
//                   - FrameCFrTOCRead()
//                   - FrameCFrTOCQuery(FR_TOC_FIELD_NFRAME, ...)
//                   - FrameCFrTOCQuery(FR_TOC_FIELD_GTIME, gtimes, nframe)
//                   - FrameCFrTOCQuery(FR_TOC_FIELD_DT,    dts,    nframe)
//                 This is the "smoking-gun" path for issue #251: every
//                 XLALFrFileQueryNFrame / QueryGTime / QueryDt call from
//                 LALFrame's stream layer (LALFrStream.c:676-707) reduces
//                 to exactly these three FrameCFrTOCQuery calls
//                 (LALFrameC.c:299-348).
//
//   PASS_C_C_PF   C-API per-frame
//                   - FrameCFileOpen()
//                   - loop: FrameCFrameHRead(file, i) + FrameCFrameHQuery
//                           for FRAME_H_FIELD_GTIME / FRAME_H_FIELD_DT
//                 Exercises the writer-side accessors that #251 ultimately
//                 broke (the C-API FrameH ctor stored Dt in the wrong slot,
//                 so per-frame queries returned 37 instead of 1800).
//
// Why three passes?
// -----------------
// They let us localise where a duration is being lost when one of them
// disagrees with the others:
//
//   - A and B agree but C disagrees ........ bug in per-frame FrameH reader
//                                            (`framecpp/src/CInterface/FrameH.cc`)
//   - B disagrees with A ................... bug in the TOC accessor layer
//                                            (`framecpp/src/CInterface/FrTOC.cc`)
//   - All three agree on a wrong value ..... file itself is wrong; bug is on
//                                            the writer side (this was the
//                                            actual diagnosis for #251).
//
// What broke in #251
// ------------------
// `makefakedata_v5 --duration=1800` writes a single-frame 1800-second .gwf
// via LALFrame. Under LAL_FRAME_LIBRARY=FrameC that goes through FrameCPP's
// C-API, where `FrameC::FrameH::FrameH(...)` constructed the C++ FrameH with
// `Dt` and `dataQuality` swapped (commit 44bb9385a had removed ULeapS from
// the underlying ctor but failed to update this call site). The TOC of the
// resulting file then held `dt=37` and `dataQuality=1800` instead of
// `dt=1800` and `dataQuality=0`. All three passes here read back `37` on
// such a file; after the fix, all three read back `1800`.
//
// How to run
// ----------
// Input file path (in priority order):
//   - environment variable ISSUE_251_GWF,
//   - first positional argument,
//   - if neither is provided, the test writes its own 1800-second single-channel
//     fixture into a temporary file using the same FrameCPP C-API write path
//     that LALPulsar's makefakedata_v5 uses (FrameCFrameHAlloc →
//     FrameCFrProcChanAlloc → FrameCFrChanVectorSet → FrameCFrameHFrChanAdd →
//     FrameCFrameHWrite → FrameCFileClose). The fixture is small (a 16 Hz
//     time series, ~225 KB before compression) and is removed at exit. This
//     lets CTest run the regression without external data.
//
// Pass/fail assertion:
//   - environment variable ISSUE_251_MIN_SPAN. When set to a number, the
//     program returns exit code 1 if any pass reports a span smaller than
//     that threshold. When the self-written fixture path is used (no env var,
//     no argv path), ISSUE_251_MIN_SPAN defaults to 1800 so the test is a
//     real regression: pre-fix it exits 1, post-fix it exits 0.
//     Set ISSUE_251_MIN_SPAN=0 to force purely-diagnostic mode.
//
// Exit codes
// ----------
//   0  - success
//   1  - at least one pass returned span < expected
//   2  - C-API call failed (file unreadable, write failed, TOC missing, etc.)
// ----------------------------------------------------------------------------

#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unistd.h>
#include <vector>

// C++ OOInterface: the higher-level API. Pass A uses these.
#include "framecpp/IFrameStream.hh"
#include "framecpp/FrameH.hh"
#include "framecpp/FrTOC.hh"

// C-API headers must be brought in with C linkage so this translation unit
// (compiled as C++) sees the same name-mangling-free symbols that the
// libframecppc.so exports. Passes B and C use these.
extern "C" {
#include "framecppc/FrChan.h"
#include "framecppc/FrameC.h"
#include "framecppc/FrameH.h"
#include "framecppc/FrTOC.h"
#include "framecppc/FrVect.h"
#include "framecppc/Stream.h"
}

namespace
{
// FrameCPP returns GPS times as (seconds, nanoseconds) pairs. We collapse
// them to a double once per frame so per-frame arithmetic (subtraction for
// total span) stays in a single, simple type.
double
gps_to_seconds( unsigned int sec, unsigned int nan )
{
    return static_cast< double >( sec ) + 1e-9 * static_cast< double >( nan );
}

// The FrameCPP C-API reports errors out-of-band via a `FrameCError*` that
// the caller passes by address. A non-null `err` after a call means the
// call failed; calling code is expected to check explicitly. We treat any
// such failure as fatal for this diagnostic tool — there's nothing useful
// to print if the file can't even be opened or its TOC can't be read.
void
check_c( const char* call, FrameCError* err )
{
    if ( err )
    {
        std::cerr << "FAIL: " << call << ": " << err->s_message
                  << " (errno=" << err->s_errno << ")\n";
        std::exit( 2 );
    }
}

// ----------------------------------------------------------------------------
// Self-contained fixture writer
// ----------------------------------------------------------------------------
// Writes a single-frame 1800-second .gwf using the FrameCPP C-API in the same
// shape LALFrame uses for LAL_FRAME_LIBRARY=FrameC (matches the body of the
// macro `DEFINE_FR_PROC_CHAN_ADD_TS_FUNCTION` in lalframe/lib/LALFrameIO.c
// and the implementation of `XLALFrameUFrameHAlloc_FrameC_` in
// lalframe/lib/LALFrameC.c). The call sequence is:
//
//   FrameCFrameHAlloc("issue-251", gtime, dt=1800, frnum=0)
//     -- exercises the C-API ctor that was the bug site for #251.
//   FrameCFrProcChanAlloc("issue-251:ch", TIME_SERIES, UNKNOWN, FR_VECT_8R, n)
//   FrameCFrChanVectorQuery(... FR_VECT_FIELD_DATA, &buf ...)
//   <fill buf with n samples>
//   FrameCFrChanVectorSet(... NAME, NX, DX, START_X, UNIT_X, UNIT_Y ...)
//   FrameCFrChanSet(... TIME_OFFSET, T_RANGE, SAMPLE_RATE ...)
//   FrameCFrameHFrChanAdd(frame, chan)
//   FrameCFrChanFree(chan)              -- the frame owns it now
//   FrameCFileOpen(path, OUTPUT)
//   FrameCFrameHWrite(file, frame)
//   FrameCFileClose(file)
//   FrameCFrameHFree(frame)
//
// Sample rate is kept low (16 Hz) so the fixture stays small: 16 * 1800 =
// 28,800 doubles ~ 225 KB raw, well under 100 KB after RAW->GZIP compression.
// The actual sample values are a low-amplitude sinusoid so the data isn't
// uniform zero (defends against a hypothetical future zero-detection bug
// that might short-circuit a "trivial" buffer).
//
// On any C-API failure the helper returns false; the caller treats that as a
// test failure (exit 2). Filename is borrowed from `out_path` and overwritten.
bool
write_fixture( const std::string& out_path,
               double             dt_seconds,
               unsigned int       gps_seconds,
               const std::string& channel_name )
{
    const double       sample_rate = 16.0; // Hz; keeps the fixture small.
    const std::size_t  ndata =
        static_cast< std::size_t >( dt_seconds * sample_rate );
    const double dx = 1.0 / sample_rate;   // sample period in seconds

    std::cout << "WRITER: " << out_path << " gps=" << gps_seconds
              << " dt=" << dt_seconds << " sample_rate=" << sample_rate
              << " ndata=" << ndata << "\n";

    FrameCError* err = nullptr;
    FrameCErrorInit( &err );

    // 1) Allocate the frame header. This is the call that was buggy: before
    //    the fix the C-API thunk passed args in (..., GTime, ULeapS, Dt)
    //    order, but ULeapS had been removed from the underlying ctor so Dt
    //    landed in the dataQuality slot and ULeapS landed in dt. The post-fix
    //    .gwf this writes will therefore have dt=1800 in its TOC.
    gpstime_t   gtime = { gps_seconds, 0 };
    frame_h_t*  frame = FrameCFrameHAlloc( &err,
                                           "issue-251",
                                           gtime,
                                           dt_seconds,
                                           /*FrameNumber=*/0 );
    if ( err )
    {
        std::cerr << "WRITER: FrameCFrameHAlloc failed: "
                  << err->s_message << "\n";
        return false;
    }

    // 2) Allocate a proc-data time-series channel sized to hold the samples.
    //    FrameCFrProcChanAlloc allocates the underlying FrVect buffer; we
    //    don't need to size the data array ourselves.
    fr_chan_t* chan = FrameCFrProcChanAlloc( &err,
                                             channel_name.c_str( ),
                                             FR_PROC_TYPE_TIME_SERIES,
                                             FR_PROC_SUB_TYPE_UNKNOWN,
                                             FR_VECT_8R, // REAL_8 / double
                                             ndata );
    if ( err )
    {
        std::cerr << "WRITER: FrameCFrProcChanAlloc: "
                  << err->s_message << "\n";
        FrameCFrameHFree( &err, frame );
        return false;
    }

    // 3) Get a pointer to the channel's data buffer and fill it with a simple
    //    sinusoid (just so the data isn't all-zeros - see helper comment).
    fr_vect_data_t buf = nullptr;
    FrameCFrChanVectorQuery( &err,
                             chan,
                             FR_VECT_FIELD_DATA,
                             &buf,
                             FR_VECT_FIELD_LAST );
    if ( err || !buf )
    {
        std::cerr << "WRITER: FrameCFrChanVectorQuery(DATA): "
                  << ( err ? err->s_message : "null buffer" ) << "\n";
        FrameCFrChanFree( &err, chan );
        FrameCFrameHFree( &err, frame );
        return false;
    }
    double* samples = reinterpret_cast< double* >( buf );
    for ( std::size_t i = 0; i < ndata; ++i )
    {
        samples[ i ] = 0.5 * std::sin( 2.0 * M_PI * 1.0 * i * dx );
    }

    // 4) Set vector metadata (name, per-dim NX/DX/START_X/UNITs). The vector
    //    is 1-D for a time-series, so all "...,1," arguments below pass an
    //    array of length 1. The C-API uses variadic interface; field tag
    //    followed by data, terminated by FR_VECT_FIELD_LAST.
    fr_vect_nx_t     nx_arr[ 1 ]    = { static_cast< fr_vect_nx_t >( ndata ) };
    fr_vect_dx_t     dx_arr[ 1 ]    = { dx };
    fr_vect_startx_t x0_arr[ 1 ]    = { 0.0 };
    fr_vect_unit_x_t unitx_arr[ 1 ] = { "s" };
    FrameCFrChanVectorSet( &err,
                           chan,
                           FR_VECT_FIELD_NAME,    channel_name.c_str( ),
                           FR_VECT_FIELD_NX,      nx_arr,    1u,
                           FR_VECT_FIELD_DX,      dx_arr,    1u,
                           FR_VECT_FIELD_START_X, x0_arr,    1u,
                           FR_VECT_FIELD_UNIT_X,  unitx_arr, 1u,
                           FR_VECT_FIELD_UNIT_Y,  "strain",
                           FR_VECT_FIELD_LAST );
    if ( err )
    {
        std::cerr << "WRITER: FrameCFrChanVectorSet: "
                  << err->s_message << "\n";
        FrameCFrChanFree( &err, chan );
        FrameCFrameHFree( &err, frame );
        return false;
    }

    // 5) Set channel-level fields: time-offset (0 within the frame),
    //    t-range (= frame dt), and sample rate. T_RANGE is the value LALFrame
    //    pulls back out as the channel's effective duration on read.
    FrameCFrChanSet( &err,
                     chan,
                     FR_CHAN_FIELD_TIME_OFFSET,
                     (fr_chan_time_offset_t)0.0,
                     FR_CHAN_FIELD_T_RANGE,
                     (fr_chan_t_range_t)dt_seconds,
                     FR_CHAN_FIELD_SAMPLE_RATE,
                     (fr_chan_sample_rate_t)sample_rate,
                     FR_CHAN_FIELD_LAST );
    if ( err )
    {
        std::cerr << "WRITER: FrameCFrChanSet: " << err->s_message << "\n";
        FrameCFrChanFree( &err, chan );
        FrameCFrameHFree( &err, frame );
        return false;
    }

    // 6) Apply gzip compression to keep the on-disk fixture small.
    FrameCFrChanVectorCompress( &err, chan, FR_VECT_COMPRESS_GZIP );
    if ( err )
    {
        std::cerr << "WRITER: VectorCompress: " << err->s_message << "\n";
        FrameCFrChanFree( &err, chan );
        FrameCFrameHFree( &err, frame );
        return false;
    }

    // 7) Attach the channel to the frame. After this, the frame owns the
    //    channel; we still call FrChanFree to drop our handle (the underlying
    //    object stays alive via the frame's reference).
    FrameCFrameHFrChanAdd( &err, frame, chan );
    if ( err )
    {
        std::cerr << "WRITER: FrameHFrChanAdd: " << err->s_message << "\n";
        FrameCFrChanFree( &err, chan );
        FrameCFrameHFree( &err, frame );
        return false;
    }
    FrameCFrChanFree( &err, chan );

    // 8) Open output stream, write the frame, close. FrameCFileClose finalises
    //    the on-disk TOC and end-of-file checksums.
    fr_file_t* file = FrameCFileOpen( &err, out_path.c_str( ),
                                      FRAMEC_FILE_MODE_OUTPUT );
    if ( err )
    {
        std::cerr << "WRITER: FileOpen(OUTPUT): "
                  << err->s_message << "\n";
        FrameCFrameHFree( &err, frame );
        return false;
    }
    FrameCFrameHWrite( &err, file, frame );
    if ( err )
    {
        std::cerr << "WRITER: FrameHWrite: " << err->s_message << "\n";
        FrameCFileClose( &err, file );
        FrameCFrameHFree( &err, frame );
        return false;
    }
    FrameCFileClose( &err, file );
    if ( err )
    {
        std::cerr << "WRITER: FileClose: " << err->s_message << "\n";
        FrameCFrameHFree( &err, frame );
        return false;
    }
    FrameCFrameHFree( &err, frame );
    std::cout << "WRITER: wrote " << out_path << "\n";
    return true;
}

// ----------------------------------------------------------------------------
// PASS A — C++ OOInterface
// ----------------------------------------------------------------------------
// Opens the file via IFrameFStream, reads the TOC arrays via
// stream.GetTOC()->GetGTime{S,N}() and GetDt(), then walks each frame
// individually via stream.ReadFrameN(i) and queries FrameH::GetGTime()/GetDt().
// Returns the per-frame span (or 0 for empty files).
double
pass_a_cpp( const std::string& path )
{
    std::cout << "PASS_A_CPP: open " << path << "\n";

    // IFrameFStream's constructor handles parsing the FrHeader, reading the
    // TOC offset from the end of the file, etc. Any I/O failure throws.
    FrameCPP::IFrameFStream stream( path.c_str( ) );
    std::cout << "PASS_A_CPP: FrameLibraryName=" << stream.FrameLibraryName( )
              << " version=" << stream.Version( ) << "\n";

    // `auto` here is the IFrameFStream::size_type, which is unsigned. We
    // strip const off it below when we need a mutable loop counter.
    auto n = stream.GetNumberOfFrames( );
    std::cout << "PASS_A_CPP: GetNumberOfFrames=" << n << "\n";

    // ---- TOC sub-pass ------------------------------------------------------
    // The TOC stores parallel arrays of length nFrame: one GTime per frame
    // (seconds + nanoseconds in two arrays), one dt per frame. The TOC may
    // be absent (some streamed files), in which case GetTOC() throws.
    try
    {
        auto toc = stream.GetTOC( );
        if ( toc )
        {
            const auto& s_arr = toc->GetGTimeS( );
            const auto& n_arr = toc->GetGTimeN( );
            const auto& d_arr = toc->GetDt( );
            std::cout << "PASS_A_CPP: TOC nFrame=" << toc->GetNFrame( )
                      << " (gtimeS.size=" << s_arr.size( )
                      << " dt.size=" << d_arr.size( ) << ")\n";

            // Print one row per frame so a regression's per-frame dt is
            // visible (not just the aggregated span).
            for ( std::size_t i = 0; i < s_arr.size( ); ++i )
            {
                std::cout << "PASS_A_CPP:   toc[" << i
                          << "] GTime=" << s_arr[ i ] << "." << std::setw( 9 )
                          << std::setfill( '0' ) << n_arr[ i ]
                          << std::setfill( ' ' ) << " dt=" << d_arr[ i ] << "\n";
            }

            // Span = end of last frame - start of first frame. For a healthy
            // 1800-s single-frame file this prints 1800. Under the #251 bug
            // it printed 37.
            if ( !s_arr.empty( ) )
            {
                double t0 = gps_to_seconds( s_arr.front( ), n_arr.front( ) );
                double tN = gps_to_seconds( s_arr.back( ), n_arr.back( ) ) +
                    d_arr.back( );
                std::cout << "PASS_A_CPP: TOC span = " << ( tN - t0 ) << "\n";
            }
        }
        else
        {
            std::cout << "PASS_A_CPP: GetTOC returned null\n";
        }
    }
    catch ( const std::exception& ex )
    {
        std::cout << "PASS_A_CPP: GetTOC threw: " << ex.what( ) << "\n";
    }

    // ---- Per-frame sub-pass ------------------------------------------------
    // ReadFrameN(i) materializes the FrameH object for frame i — heavier than
    // a TOC peek, but it exercises the FrameH-decoding path rather than only
    // the TOC. Mismatch between this number and the TOC sub-pass would
    // localise a bug to one or the other.
    double span_pf = 0.0;
    double t0 = 0.0, tN = 0.0;
    // size_type may be a const-qualified typedef; strip const so we can ++i.
    for ( std::remove_const_t< decltype( n ) > i = 0; i < n; ++i )
    {
        auto frame = stream.ReadFrameN( i );
        if ( !frame )
        {
            std::cout << "PASS_A_CPP:   ReadFrameN(" << i << ") -> null\n";
            continue;
        }
        const auto gt = frame->GetGTime( );
        const double dt = frame->GetDt( );
        const double tStart = gps_to_seconds( gt.GetSeconds( ),
                                              gt.GetNanoseconds( ) );
        if ( i == 0 )
            t0 = tStart;
        tN = tStart + dt;
        std::cout << "PASS_A_CPP:   frame[" << i << "] GTime=" << gt.GetSeconds( )
                  << "." << std::setw( 9 ) << std::setfill( '0' )
                  << gt.GetNanoseconds( ) << std::setfill( ' ' )
                  << " dt=" << dt << "\n";
    }
    if ( n > 0 )
    {
        span_pf = tN - t0;
        std::cout << "PASS_A_CPP: per-frame span = " << span_pf << "\n";
    }
    return span_pf;
}

// ----------------------------------------------------------------------------
// PASS B — C-API via TOC (the LALFrame path)
// ----------------------------------------------------------------------------
// This pass exactly mirrors what LALFrame does on LAL_FRAME_LIBRARY=FrameC:
//
//   LALFrame                                  FrameCPP C-API
//   -----------------------------------       -------------------------------
//   XLALFrFileQueryNFrame(file)        ->     FrameCFrTOCQuery(NFRAME)
//   XLALFrFileQueryGTime(file, last)   ->     FrameCFrTOCQuery(GTIME, arr, n)
//   XLALFrFileQueryDt(file, last)      ->     FrameCFrTOCQuery(DT,    arr, n)
//
// So if this pass reports a wrong span, LALFrame will too, and downstream
// LALPulsar code will go on to fail with the exact `duration=...` message
// the user originally saw.
//
// Returns the TOC-derived span.
double
pass_b_c_toc( const std::string& path )
{
    std::cout << "PASS_B_C_TOC: open " << path << "\n";

    FrameCError* err = nullptr;
    FrameCErrorInit( &err );

    fr_file_t* file =
        FrameCFileOpen( &err, path.c_str( ), FRAMEC_FILE_MODE_INPUT );
    check_c( "FrameCFileOpen", err );

    fr_toc_t* toc = FrameCFrTOCRead( &err, file );
    check_c( "FrameCFrTOCRead", err );

    // Query 1: number of frames. Same call LALFrame makes first.
    fr_toc_nframe_t nframe = 0;
    FrameCFrTOCQuery( &err,
                      toc,
                      FR_TOC_FIELD_NFRAME,
                      &nframe,
                      FR_TOC_FIELD_LAST );
    check_c( "FrameCFrTOCQuery(NFRAME)", err );
    std::cout << "PASS_B_C_TOC: NFRAME=" << nframe << "\n";

    double span = 0.0;
    if ( nframe > 0 )
    {
        // The GTIME/DT TOC queries fill caller-provided arrays of length
        // nframe. The C-API uses a variadic interface: (field, ptr, count,
        // FR_TOC_FIELD_LAST). Note that the order of the variadic args
        // differs from FRAME_H_FIELD_* queries, which take (field, ptr).
        std::vector< gpstime_t > gtimes( nframe );
        std::vector< fr_toc_dt_element_t > dts( nframe );

        FrameCFrTOCQuery( &err,
                          toc,
                          FR_TOC_FIELD_GTIME,
                          gtimes.data( ),
                          nframe,
                          FR_TOC_FIELD_LAST );
        check_c( "FrameCFrTOCQuery(GTIME)", err );

        FrameCFrTOCQuery( &err,
                          toc,
                          FR_TOC_FIELD_DT,
                          dts.data( ),
                          nframe,
                          FR_TOC_FIELD_LAST );
        check_c( "FrameCFrTOCQuery(DT)", err );

        for ( fr_toc_nframe_t i = 0; i < nframe; ++i )
        {
            std::cout << "PASS_B_C_TOC:   toc[" << i
                      << "] GTime=" << gtimes[ i ].sec << "."
                      << std::setw( 9 ) << std::setfill( '0' )
                      << gtimes[ i ].nan << std::setfill( ' ' )
                      << " dt=" << dts[ i ] << "\n";
        }

        // Same span definition as PASS_A: "end of last frame minus start of
        // first frame", in seconds.
        const double t0 =
            gps_to_seconds( gtimes.front( ).sec, gtimes.front( ).nan );
        const double tN =
            gps_to_seconds( gtimes.back( ).sec, gtimes.back( ).nan ) +
            dts.back( );
        span = tN - t0;
        std::cout << "PASS_B_C_TOC: span = " << span << "\n";
    }

    // Both the TOC handle and the stream require explicit cleanup; the C-API
    // doesn't have RAII wrappers.
    FrameCFrTOCFree( &err, toc );
    check_c( "FrameCFrTOCFree", err );

    FrameCFileClose( &err, file );
    check_c( "FrameCFileClose", err );
    return span;
}

// ----------------------------------------------------------------------------
// PASS C — C-API per-frame
// ----------------------------------------------------------------------------
// Reads each FrameH individually via FrameCFrameHRead(file, i), then queries
// it for GTime + dt. This exercises the writer-side accessors that issue
// #251 directly affected: a per-frame GetDt query on a frame written by the
// buggy C-API ctor returned 37 instead of the requested duration.
//
// `nframe_hint` lets main() pass the NFRAME we already pulled out of the
// TOC so we know when to stop. If we don't have a hint we still cap the
// loop at a generous upper bound to avoid spinning forever on a
// pathological file.
double
pass_c_c_per_frame( const std::string& path, fr_toc_nframe_t nframe_hint )
{
    std::cout << "PASS_C_C_PF: open " << path << "\n";

    FrameCError* err = nullptr;
    FrameCErrorInit( &err );

    fr_file_t* file =
        FrameCFileOpen( &err, path.c_str( ), FRAMEC_FILE_MODE_INPUT );
    check_c( "FrameCFileOpen", err );

    // The C-API does not expose nFrame independently of the TOC, and
    // FrameCFrameHRead simply returns null when called past the end of the
    // file. We therefore cap iteration at 4 x nframe_hint as a sanity net so
    // that a corrupted file (where Read keeps returning frames forever) does
    // not turn this test into an infinite loop. The factor is generous so we
    // still walk every legitimate frame even if hint was rough.
    const fr_toc_nframe_t hard_max = nframe_hint
        ? ( nframe_hint * 4u + 16u )
        : 100000u;

    double t0 = 0.0, tN = 0.0;
    fr_toc_nframe_t count = 0;
    for ( fr_toc_nframe_t i = 0; i < hard_max; ++i )
    {
        frame_h_t* frame =
            FrameCFrameHRead( &err, file, (frame_h_offset_t)i );
        if ( err || !frame )
        {
            // End of frames OR a read error. Either way, stop. We deliberately
            // clear err and don't escalate: a clean end-of-data is far more
            // common than a corrupt frame here, and the per-frame path is
            // diagnostic, not authoritative.
            err = nullptr;
            break;
        }

        // FRAME_H_FIELD_* queries pack into a single variadic call. Each
        // field tag is followed by an output pointer; FRAME_H_FIELD_LAST
        // terminates the list. This is the same call shape LALFrame uses
        // when LALFrameC asks for GTime/Dt from a freshly read FrameH.
        frame_h_gtime_t gtime;
        frame_h_dt_t    dt;
        FrameCFrameHQuery( &err,
                           frame,
                           FRAME_H_FIELD_GTIME,
                           &gtime,
                           FRAME_H_FIELD_DT,
                           &dt,
                           FRAME_H_FIELD_LAST );
        check_c( "FrameCFrameHQuery", err );

        const double tStart = gps_to_seconds( gtime.sec, gtime.nan );
        if ( count == 0 )
            t0 = tStart;
        tN = tStart + dt;

        std::cout << "PASS_C_C_PF:   frame[" << i << "] GTime=" << gtime.sec
                  << "." << std::setw( 9 ) << std::setfill( '0' )
                  << gtime.nan << std::setfill( ' ' ) << " dt=" << dt << "\n";

        FrameCFrameHFree( &err, frame );
        check_c( "FrameCFrameHFree", err );
        ++count;
    }
    std::cout << "PASS_C_C_PF: frames walked = " << count << "\n";

    double span = 0.0;
    if ( count > 0 )
    {
        span = tN - t0;
        std::cout << "PASS_C_C_PF: span = " << span << "\n";
    }

    FrameCFileClose( &err, file );
    check_c( "FrameCFileClose", err );
    return span;
}
} // namespace

int
main( int argc, char** argv )
{
    // Input precedence: env var first (so CTest can drive the test via
    // set_tests_properties(... ENVIRONMENT ISSUE_251_GWF=...)), positional
    // argument second (handy for interactive use: `./test /path/to/file.gwf`).
    // If neither is supplied we generate our own 1800-second single-channel
    // fixture so the test is genuinely self-contained.
    const char* env_path = std::getenv( "ISSUE_251_GWF" );
    const char* arg_path = ( argc > 1 ) ? argv[ 1 ] : nullptr;

    std::string path;
    std::string tmp_fixture; // non-empty -> we wrote it and must delete it
    bool        self_fixture = false;
    if ( env_path && *env_path )
    {
        path = env_path;
    }
    else if ( arg_path && *arg_path )
    {
        path = arg_path;
    }
    else
    {
        // Build a unique temp filename ending in .gwf. mkstemps creates the
        // file (so the name is reserved); we close+remove the empty handle so
        // FrameCFileOpen can recreate it at the same path.
        char tmpl[] = "/tmp/issue_251_fixture_XXXXXX.gwf";
        int  fd = mkstemps( tmpl, /*suffixlen=*/4 );
        if ( fd < 0 )
        {
            std::cerr << "mkstemps failed: " << std::strerror( errno ) << "\n";
            return 2;
        }
        close( fd );
        std::remove( tmpl );
        tmp_fixture = tmpl;
        path = tmpl;
        if ( !write_fixture( path,
                             /*dt_seconds=*/1800.0,
                             /*gps_seconds=*/1238112018,
                             /*channel_name=*/"H1:issue251" ) )
        {
            return 2;
        }
        self_fixture = true;
    }

    // ---- Run pass A and capture its span. Wrapped in try/catch because the
    //      C++ OOInterface throws on I/O / parse failures, and we want
    //      passes B and C to still run so the diagnostic output is complete.
    double span_a = 0.0;
    fr_toc_nframe_t nframe_hint = 0;
    try
    {
        span_a = pass_a_cpp( path );
    }
    catch ( const std::exception& ex )
    {
        std::cerr << "PASS_A_CPP threw: " << ex.what( ) << "\n";
    }

    // ---- Peek at NFRAME via the C-API so pass C has a tight upper bound on
    //      its loop. Done in a tiny separate block (rather than re-using the
    //      pass-B file handle) to keep each pass self-contained. Any failure
    //      here just falls through; pass C will use its built-in 100k cap.
    try
    {
        FrameCError* err = nullptr;
        FrameCErrorInit( &err );
        fr_file_t* f =
            FrameCFileOpen( &err, path.c_str( ), FRAMEC_FILE_MODE_INPUT );
        if ( !err )
        {
            fr_toc_t* toc = FrameCFrTOCRead( &err, f );
            if ( !err )
            {
                FrameCFrTOCQuery( &err,
                                  toc,
                                  FR_TOC_FIELD_NFRAME,
                                  &nframe_hint,
                                  FR_TOC_FIELD_LAST );
                FrameCFrTOCFree( &err, toc );
            }
            FrameCFileClose( &err, f );
        }
    }
    catch ( ... )
    {
        // Best-effort hint; if it fails we just don't have a tight cap.
    }

    const double span_b = pass_b_c_toc( path );
    const double span_c = pass_c_c_per_frame( path, nframe_hint );

    // ---- Pass/fail assertion. Default behaviour depends on how the input
    //      arrived: when we wrote our own fixture we know the expected span
    //      is exactly 1800 s, so we assert that by default. When the caller
    //      provided a path we leave it diagnostic unless they opt in via
    //      ISSUE_251_MIN_SPAN. Either way the env var overrides, and
    //      ISSUE_251_MIN_SPAN=0 explicitly turns the assertion off.
    double min_span = self_fixture ? 1800.0 : 0.0;
    if ( const char* s = std::getenv( "ISSUE_251_MIN_SPAN" ) )
    {
        if ( *s )
            min_span = std::strtod( s, nullptr );
    }

    int rc = 0;
    if ( min_span > 0.0 )
    {
        if ( span_a < min_span || span_b < min_span || span_c < min_span )
        {
            std::cerr << "FAIL: span < min_span=" << min_span
                      << " (A=" << span_a << " B=" << span_b
                      << " C=" << span_c << ")\n";
            rc = 1;
        }
        else
        {
            std::cout << "OK: all spans >= " << min_span << "\n";
        }
    }

    // Clean up the temp fixture if we wrote one. Done after the assertion so
    // a failed run still leaves no garbage behind.
    if ( !tmp_fixture.empty( ) )
        std::remove( tmp_fixture.c_str( ) );
    return rc;
}
