// MobileGL - MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
//   https://www.gnu.org/licenses/gpl-3.0.txt
//   https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header

// The hidden composite program a pipeline draw goes through (MG_State/GLState/Core.cpp,
// GetProgramForDraw), interrogated directly rather than through pixels.
//
// Two properties live here that the integration scenarios cannot see, because both are about
// the composite as an OBJECT rather than about what it paints:
//
//   1. WHICH stage's uniform value ends up in its single slot when several stages declare the
//      same name. The rendering cases pin the answer for the shapes an application actually
//      writes; these pin the rule itself, including the tie.
//   2. WHETHER it is the same object from one draw to the next. A composite rebuild is a full
//      synchronous Link() plus a new program identity that empties both backends' per-program
//      registries, and nothing about the resulting IMAGE would change if it happened on every
//      draw - so an assertion on pixels can never catch that regression.

#include <gtest/gtest.h>

#include <cstring>
#include <string>
#include <vector>

#include "Config.h"
#include "Includes.h"
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_Impl/GLImpl/Drawing/GL_Drawing.h"
#include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h"
#include "MG_State/GLState/Core.h"

using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;

namespace {

    // Both stages declare `u_shared`, which is the shared-header idiom (one header included by
    // every stage) and the shape that used to render nothing: the fragment stage's untouched
    // zero default overwrote the vertex stage's written value on the way into the composite.
    const char* kSharedUniformVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
uniform vec4 u_shared;
uniform vec4 u_vsOnly;
void main() { gl_Position = u_shared + u_vsOnly; }
)";

    const char* kSharedUniformFs = R"(#version 430 core
uniform vec4 u_shared;
out vec4 o_color;
void main() { o_color = u_shared; }
)";

    const char* kArrayUniformVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
uniform vec4 u_arr[4];
void main() { gl_Position = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; }
)";

    const char* kArrayUniformFs = R"(#version 430 core
uniform vec4 u_arr[4];
out vec4 o_color;
void main() { o_color = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; }
)";

    const char* kSamplerVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";

    const char* kSamplerFs = R"(#version 430 core
uniform sampler2D u_tex;
out vec4 o_color;
void main() { o_color = texture(u_tex, vec2(0.0)); }
)";

    class ProgramPipelineCompositeTest : public ::testing::Test {
    protected:
        void SetUp() override { MobileGL::Initialize(); }

        // Built by hand rather than through glCreateShaderProgramv, for the reason AsyncLinkTest
        // gives: that entry point detaches the shader right after linking, so a relink would
        // leave the stage program with nothing to composite from - and one of the cases below
        // relinks on purpose.
        GLuint MakeSeparableProgram(const GLenum stage, const char* source) {
            const GLuint shader = CreateShader(stage);
            ShaderSource(shader, 1, &source, nullptr);
            CompileShader(shader);
            const GLuint program = CreateProgram();
            ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
            AttachShader(program, shader);
            LinkProgram(program);
            GLint linked = GL_FALSE;
            GetProgramiv(program, GL_LINK_STATUS, &linked);
            EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link";
            return program;
        }

        // The composite the next draw would run, settled.
        static SharedPtr<MG_State::GLState::ProgramObject> DrawProgram() {
            return MG_State::pGLContext->GetProgramForDraw();
        }

        // A uniform's value read out of a program's own shadow, by name. This is what the draw
        // would upload, which is the thing under test - glGetUniform* would answer the same for
        // the STAGE programs but has no way to name the composite at all.
        static std::vector<float> ReadVec4(MG_State::GLState::ProgramObject& program, const String& name) {
            const Int location = program.GetUniformLocation(name);
            if (location < 0) return {};
            const Uint offset = program.GetUniformOffset(static_cast<Uint>(location));
            const auto* ubo = static_cast<const char*>(program.GetUBOData());
            if (ubo == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset ||
                offset + 4 * sizeof(float) > program.GetUBOSize()) {
                return {};
            }
            std::vector<float> value(4);
            std::memcpy(value.data(), ubo + offset, 4 * sizeof(float));
            return value;
        }
    };

} // namespace

// ---------------------------------------------------------------------------------------
// Which stage wins the composite's single slot
// ---------------------------------------------------------------------------------------

// THE defect. Both stages declare `u_shared`; only the VERTEX program is ever written to.
// Walking the stages in order and copying every active uniform unconditionally meant the
// fragment stage's untouched zero default landed last and won, so the composite drew zeros - a
// whole frame of nothing, from a program that had been set up entirely correctly.
TEST_F(ProgramPipelineCompositeTest, AWrittenStageValueIsNotClobberedByAnotherStagesUntouchedDeclaration) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    // Exactly what an application does: point glUniform* at the vertex stage and write there.
    // The fragment program is never written to and holds nothing but GL's zero default.
    ActiveShaderProgram(pipeline, vs);
    const GLint location = GetUniformLocation(vs, "u_shared");
    ASSERT_GE(location, 0);
    const float written[4] = {0.25f, 0.5f, 0.75f, 1.0f};
    Uniform4fv(location, 1, written);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    const std::vector<float> value = ReadVec4(*composite, "u_shared");
    ASSERT_EQ(value.size(), 4u) << "u_shared has no backing storage in the composite";
    EXPECT_EQ(value, (std::vector<float>{0.25f, 0.5f, 0.75f, 1.0f}))
        << "the fragment stage's untouched declaration overwrote the vertex stage's written value";

    // The uniform only one stage declares is unaffected either way; it is here so a mirror that
    // copied nothing at all would not pass this case by accident.
    ActiveShaderProgram(pipeline, vs);
    const GLint vsOnly = GetUniformLocation(vs, "u_vsOnly");
    ASSERT_GE(vsOnly, 0);
    const float other[4] = {1.0f, 2.0f, 3.0f, 4.0f};
    Uniform4fv(vsOnly, 1, other);
    const auto refreshed = DrawProgram();
    EXPECT_EQ(ReadVec4(*refreshed, "u_vsOnly"), (std::vector<float>{1.0f, 2.0f, 3.0f, 4.0f}));
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// The tie the fix cannot make disappear: BOTH stages were written, and the composite still has
// one slot. The documented rule is last WRITTEN-TO graphics stage wins, in ShaderStage enum
// order - deterministic, and reachable only by a stage holding a real application value.
TEST_F(ProgramPipelineCompositeTest, WhenBothStagesWereWrittenTheLastGraphicsStageWins) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);

    const float fromVs[4] = {1.0f, 1.0f, 1.0f, 1.0f};
    const float fromFs[4] = {2.0f, 2.0f, 2.0f, 2.0f};
    // Written in the order VS then FS...
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
    ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);
    EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{2.0f, 2.0f, 2.0f, 2.0f}));

    // ...and in the order FS then VS. The answer is the same, because the rule is stage order
    // and not write order - which is the honest statement of what the dirty set can support.
    ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs);
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);
    EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{2.0f, 2.0f, 2.0f, 2.0f}))
        << "the both-written tie must be decided by stage order, deterministically";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// The both-written tie again, through the case that has no BYTES to move: the fragment stage
// writes the value it was already holding.
//
// The refresh gate is built out of counters that move when bytes move (the UBO content
// version, the backend state version), and both write funnels drop a value-identical write
// before bumping either. So this write enlarges the write SET - it makes the fragment stage
// the last written-to stage for `u_shared`, which is what decides the slot - while moving
// nothing else. Without a generation on the set itself the gate never trips and the draw keeps
// the vertex stage's value.
TEST_F(ProgramPipelineCompositeTest, AValueIdenticalWriteStillTakesTheSlotForItsStage) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);

    const float fromVs[4] = {5.0f, 5.0f, 5.0f, 5.0f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs);
    ASSERT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{5.0f, 5.0f, 5.0f, 5.0f}));

    // The fragment program's u_shared already reads all-zero, so this write changes not one
    // byte of its shadow - and must still hand it the composite's slot.
    const float zeros[4] = {0.0f, 0.0f, 0.0f, 0.0f};
    ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, zeros);
    ASSERT_EQ(GetError(), GL_NO_ERROR);
    EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}))
        << "a write that moved no bytes never reached the refresh gate";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// A stage program that recorded NONE of its writes, because nothing ever armed its tracking
// latch: the mirror has to fall back to carrying everything rather than carrying nothing.
// Mirroring nothing would have been a fresh regression on a shape that worked before the dirty
// set existed.
//
// The shape used to be reachable through glUseProgramStages, which accepted a program that was
// never linked as separable. It no longer is: GL 4.6 core 7.4 requires the LATCHED
// PROGRAM_SEPARABLE flag and MobileGL now enforces it, and arming that flag is the very thing
// that arms the tracking latch - so no program the entry point accepts can be in this state. The
// fallback is therefore unreachable from GL and is exercised through the state layer instead,
// which is the only way left to keep it covered rather than deleting the coverage with the hole.
TEST_F(ProgramPipelineCompositeTest, ANonSeparableStageProgramStillMirrorsItsUniforms) {
    const char* vsSource = R"(#version 430 core
uniform vec4 u_vsOnly;
void main() { gl_Position = u_vsOnly; }
)";
    const GLuint shader = CreateShader(GL_VERTEX_SHADER);
    ShaderSource(shader, 1, &vsSource, nullptr);
    CompileShader(shader);
    const GLuint vs = CreateProgram();
    // Deliberately NO ProgramParameteri(GL_PROGRAM_SEPARABLE): this is the shape the latch
    // cannot see coming.
    AttachShader(vs, shader);
    LinkProgram(vs);
    GLint linked = GL_FALSE;
    GetProgramiv(vs, GL_LINK_STATUS, &linked);
    ASSERT_EQ(linked, GL_TRUE);

    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    // The fragment stage goes through the entry point; the vertex one cannot, so it is installed
    // directly on the pipeline object - the same call glUseProgramStages makes once it is done
    // validating, minus the validation this shape now fails.
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    ASSERT_EQ(GetError(), GL_INVALID_OPERATION)
        << "a program not linked as separable is not a legal pipeline stage";
    {
        const auto& pipelineObject = MG_State::pGLContext->MaterializeProgramPipelineObject(pipeline);
        ASSERT_NE(pipelineObject, nullptr);
        pipelineObject->SetStageProgram(ShaderStage::Vertex, MG_State::pGLContext->GetProgramObject(vs));
    }

    const float written[4] = {3.0f, 1.0f, 4.0f, 1.0f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    EXPECT_FALSE(MG_State::pGLContext->GetProgramObject(vs)->TracksUniformWrites())
        << "this case is only meaningful while the stage program records nothing";
    EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector<float>{3.0f, 1.0f, 4.0f, 1.0f}))
        << "a stage program with no write record must fall back to mirroring everything";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// glProgramUniform* addresses a program by NAME and needs neither a current program nor an
// active shader program, so it is a write path that never touches the pipeline at all. It has
// to record the write exactly like glUniform* does.
TEST_F(ProgramPipelineCompositeTest, ProgramUniformOnAnUnboundStageProgramReachesTheComposite) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);

    // Deliberately BEFORE the bind, and with no glActiveShaderProgram anywhere: the write has
    // to survive from here to a draw that has not been set up yet.
    const float written[4] = {9.0f, 8.0f, 7.0f, 6.0f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    BindProgramPipeline(pipeline);
    EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{9.0f, 8.0f, 7.0f, 6.0f}));
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// Array uniforms are written at ELEMENT locations, so the record has to be per location and not
// per name: a stage that wrote `u_arr[2]` and nothing else must carry element 2 across and
// leave the rest to whichever stage owns them.
TEST_F(ProgramPipelineCompositeTest, ArrayElementWritesMirrorPerElement) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kArrayUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kArrayUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);

    // Non-prefix on purpose: elements 1 and 3 from the vertex stage, element 2 from the fragment
    // stage, element 0 from nobody. A per-name record would have carried whole arrays and let
    // one stage's zeros take the other's elements.
    const float one[4] = {11.0f, 11.0f, 11.0f, 11.0f};
    const float three[4] = {33.0f, 33.0f, 33.0f, 33.0f};
    const float two[4] = {22.0f, 22.0f, 22.0f, 22.0f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[1]"), 1, one);
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[3]"), 1, three);
    ProgramUniform4fv(fs, GetUniformLocation(fs, "u_arr[2]"), 1, two);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    EXPECT_EQ(ReadVec4(*composite, "u_arr[0]"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}));
    EXPECT_EQ(ReadVec4(*composite, "u_arr[1]"), (std::vector<float>{11.0f, 11.0f, 11.0f, 11.0f}));
    EXPECT_EQ(ReadVec4(*composite, "u_arr[2]"), (std::vector<float>{22.0f, 22.0f, 22.0f, 22.0f}));
    EXPECT_EQ(ReadVec4(*composite, "u_arr[3]"), (std::vector<float>{33.0f, 33.0f, 33.0f, 33.0f}));
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    // A multi-element glUniform*v run marks each location it actually reaches.
    const float tail[8] = {44.0f, 44.0f, 44.0f, 44.0f, 55.0f, 55.0f, 55.0f, 55.0f};
    ActiveShaderProgram(pipeline, fs);
    Uniform4fv(GetUniformLocation(fs, "u_arr[2]"), 2, tail);
    ASSERT_EQ(GetError(), GL_NO_ERROR);
    const auto refreshed = DrawProgram();
    EXPECT_EQ(ReadVec4(*refreshed, "u_arr[2]"), (std::vector<float>{44.0f, 44.0f, 44.0f, 44.0f}));
    EXPECT_EQ(ReadVec4(*refreshed, "u_arr[3]"), (std::vector<float>{55.0f, 55.0f, 55.0f, 55.0f}))
        << "the second element of a count=2 write was never recorded";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// Relinking resets a program's uniforms to their initial values (GL 4.6 core 7.6), so the record
// of what was written has to be reset with them. If it survived, the composite built after the
// relink would be handed values the stage program no longer holds.
TEST_F(ProgramPipelineCompositeTest, RelinkingAStageProgramClearsWhatItHadWritten) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);

    const float written[4] = {5.0f, 6.0f, 7.0f, 8.0f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written);
    ASSERT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{5.0f, 6.0f, 7.0f, 8.0f}));

    LinkProgram(vs);
    GLint linked = GL_FALSE;
    GetProgramiv(vs, GL_LINK_STATUS, &linked);
    ASSERT_EQ(linked, GL_TRUE);

    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector<float>{0.0f, 0.0f, 0.0f, 0.0f}))
        << "a relinked stage program carried its pre-relink value into the new composite";
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    // ...and writing again after the relink is recorded afresh.
    const float rewritten[4] = {1.5f, 2.5f, 3.5f, 4.5f};
    ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, rewritten);
    EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector<float>{1.5f, 2.5f, 3.5f, 4.5f}));
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// ---------------------------------------------------------------------------------------
// Composite cache stability
// ---------------------------------------------------------------------------------------

// The SSO-conformance shape, and the reason the composite cache stopped being keyed on the
// backend state version: pick a stage program, then per draw set a sampler unit and draw.
// glUniform1i on a sampler bumps that version, so the signature changed on every iteration and
// every single draw threw the composite away and relinked it - glslang, SPIR-V and spirv-opt,
// synchronously, inside the draw - handing the backends a brand-new program identity each time.
//
// Asserted on the composite POINTER, which is the honest observable: it is the object both
// backends key their per-program registries and pipeline memos on, so "same pointer" is exactly
// the property that was lost.
TEST_F(ProgramPipelineCompositeTest, ASamplerWritePerDrawDoesNotRebuildTheComposite) {
    const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSamplerVs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSamplerFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ActiveShaderProgram(pipeline, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const GLint sampler = GetUniformLocation(fs, "u_tex");
    ASSERT_GE(sampler, 0);

    const auto first = DrawProgram();
    ASSERT_NE(first, nullptr);
    const Uint64 firstLifetime = first->GetLifetimeId();
    const Int compositeSampler = first->GetUniformLocation("u_tex");
    ASSERT_GE(compositeSampler, 0);

    for (GLint unit = 0; unit < 8; ++unit) {
        Uniform1i(sampler, unit);
        const auto composite = DrawProgram();
        ASSERT_NE(composite, nullptr);
        EXPECT_EQ(composite.get(), first.get())
            << "the composite was rebuilt by a sampler-unit write at unit " << unit;
        EXPECT_EQ(composite->GetLifetimeId(), firstLifetime) << "the composite's identity changed at unit " << unit;
        // The value still has to ARRIVE - the whole point is that the mirror carries it now that
        // the rebuild no longer does.
        EXPECT_EQ(composite->GetUniformSamplerOrImageUnitIndex(static_cast<Uint>(compositeSampler)), unit)
            << "the sampler unit did not reach the composite at unit " << unit;
    }
    EXPECT_EQ(GetError(), GL_NO_ERROR);

    // A relink, by contrast, MUST replace it: that is the one thing the signature still tracks.
    LinkProgram(fs);
    GLint linked = GL_FALSE;
    GetProgramiv(fs, GL_LINK_STATUS, &linked);
    ASSERT_EQ(linked, GL_TRUE);
    const auto afterRelink = DrawProgram();
    ASSERT_NE(afterRelink, nullptr);
    EXPECT_NE(afterRelink.get(), first.get()) << "a relinked stage program must rebuild the composite";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// The monolithic path must be untouched by any of this: a plain glUseProgram program is not
// separable, records nothing, and is its own draw program.
TEST_F(ProgramPipelineCompositeTest, AMonolithicProgramRecordsNothingAndIsItsOwnDrawProgram) {
    const char* vsSource = R"(#version 430 core
uniform vec4 u_shared;
void main() { gl_Position = u_shared; }
)";
    const char* fsSource = R"(#version 430 core
uniform vec4 u_shared;
out vec4 o_color;
void main() { o_color = u_shared; }
)";
    const GLuint vsShader = CreateShader(GL_VERTEX_SHADER);
    ShaderSource(vsShader, 1, &vsSource, nullptr);
    CompileShader(vsShader);
    const GLuint fsShader = CreateShader(GL_FRAGMENT_SHADER);
    ShaderSource(fsShader, 1, &fsSource, nullptr);
    CompileShader(fsShader);

    const GLuint program = CreateProgram();
    AttachShader(program, vsShader);
    AttachShader(program, fsShader);
    LinkProgram(program);
    GLint linked = GL_FALSE;
    GetProgramiv(program, GL_LINK_STATUS, &linked);
    ASSERT_EQ(linked, GL_TRUE);

    UseProgram(program);
    const float written[4] = {1.0f, 2.0f, 3.0f, 4.0f};
    Uniform4fv(GetUniformLocation(program, "u_shared"), 1, written);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const auto drawProgram = DrawProgram();
    ASSERT_NE(drawProgram, nullptr);
    EXPECT_EQ(drawProgram->GetExternalIndex(), program) << "a current program IS the draw program";
    // Nothing was recorded, because nothing ever asked this program to be separable - which is
    // what keeps the hot uniform path free of the bookkeeping.
    EXPECT_FALSE(drawProgram->TracksUniformWrites());
    EXPECT_TRUE(drawProgram->GetWrittenUniformIndices().empty());
    EXPECT_EQ(ReadVec4(*drawProgram, "u_shared"), (std::vector<float>{1.0f, 2.0f, 3.0f, 4.0f}));

    UseProgram(0);
}

// ---------------------------------------------------------------------------------------
// The vertex stage a pre-rasterization pipeline must have
// ---------------------------------------------------------------------------------------

// GL 4.6 core 7.4.1: a pipeline whose tessellation-control, tessellation-evaluation or geometry
// stage has an executable, but which supplies no executable VERTEX shader, makes every command
// that transfers vertices an INVALID_OPERATION. MobileGL checked only "a program is current" and
// "it linked", so a geometry+fragment pipeline drew happily and rendered nothing -
// KHR-GL4x.geometry_shader.api.fs_gs_draw_call and .pipeline_program_without_active_vs.
TEST_F(ProgramPipelineCompositeTest, AGeometryPipelineWithNoVertexStageRefusesToDraw) {
    const char* kGs = R"(#version 430 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() { gl_Position = vec4(0.0); EmitVertex(); EndPrimitive(); }
)";
    const GLuint gs = MakeSeparableProgram(GL_GEOMETRY_SHADER, kGs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_GEOMETRY_SHADER_BIT, gs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    // Not vacuous: the composite has to be a healthy linked program, so that the refusal below
    // can only be the missing vertex stage and not a link that fell over on its own.
    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    ASSERT_TRUE(composite->GetLinkStatus()) << "the composite itself must link for this test to mean anything";
    ASSERT_TRUE(composite->HasLinkedShaderStage(ShaderStage::Geometry));
    ASSERT_FALSE(composite->HasLinkedShaderStage(ShaderStage::Vertex));

    DrawArrays(GL_POINTS, 0, 1);
    EXPECT_EQ(GetError(), GL_INVALID_OPERATION)
        << "a geometry stage with no vertex stage must refuse the draw";

    // A dispatch shares the same "is there a program, did it link" helper and legitimately has no
    // vertex stage; the rule must not have leaked onto it. There is no compute stage here, so the
    // error is the compute check's own - what matters is that the draw rule did not fire first
    // with a different meaning.
    for (Int drained = 0; drained < 16 && GetError() != GL_NO_ERROR; ++drained) {
    }

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
    for (Int drained = 0; drained < 16 && GetError() != GL_NO_ERROR; ++drained) {
    }
}

// ---------------------------------------------------------------------------------------------
// The composite's transform-feedback capture list.
//
// Two rules, and getting either wrong turns a working pipeline into one where EVERY draw reports
// GL_INVALID_OPERATION: an unresolvable capture name fails the composite's own link, and
// ValidateProgramForExecution rejects every draw through a pipeline whose composite did not link -
// while glValidateProgramPipeline keeps reporting TRUE.
// ---------------------------------------------------------------------------------------------

namespace {
    const char* kCaptureVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
out float v_captured;
out float v_other;
void main() { gl_Position = vec4(0.0); v_captured = 1.0; v_other = 2.0; }
)";

    // A geometry stage that re-emits nothing the vertex stage named, so a capture list taken from
    // the VERTEX program cannot resolve against it.
    const char* kPassthroughGs = R"(#version 430 core
layout(points) in;
layout(points, max_vertices = 1) out;
out gl_PerVertex { vec4 gl_Position; };
out float g_only;
void main() { gl_Position = vec4(0.0); g_only = 1.0; EmitVertex(); EndPrimitive(); }
)";

    Vector<String> CompositeCaptureNames(MG_State::GLState::ProgramObject& composite) {
        Vector<String> names;
        for (SizeT i = 0; i < composite.GetTransformFeedbackVaryingCount(); ++i) {
            if (const auto* varying = composite.GetTransformFeedbackVarying(i)) {
                names.push_back(varying->name);
            }
        }
        return names;
    }
} // namespace

// glTransformFeedbackVaryings does not take effect until the program's NEXT link (GL 4.6 core
// 7.3/11.1.2.1) and deliberately bumps no version, so a request written after the stage program's
// last link is invisible to the composite cache's signature - yet the next rebuild would pick it
// up. The capture list would then depend on whether some unrelated event happened to invalidate
// the cache. Reading the LINKED snapshot removes the whole class, and makes the existing cache key
// sufficient: linked state only moves at a link, which is exactly what the key tracks.
TEST_F(ProgramPipelineCompositeTest, CompositeCaptureListComesFromTheLinkedSnapshotNotThePendingRequest) {
    const GLuint vs = CreateProgram();
    {
        const GLuint shader = CreateShader(GL_VERTEX_SHADER);
        ShaderSource(shader, 1, &kCaptureVs, nullptr);
        CompileShader(shader);
        ProgramParameteri(vs, GL_PROGRAM_SEPARABLE, GL_TRUE);
        AttachShader(vs, shader);
        const char* captured = "v_captured";
        TransformFeedbackVaryings(vs, 1, &captured, GL_INTERLEAVED_ATTRIBS);
        LinkProgram(vs);
        GLint linked = GL_FALSE;
        GetProgramiv(vs, GL_LINK_STATUS, &linked);
        ASSERT_EQ(linked, GL_TRUE);
    }
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    {
        const auto composite = DrawProgram();
        ASSERT_NE(composite, nullptr);
        EXPECT_EQ(CompositeCaptureNames(*composite), (Vector<String>{"v_captured"}));
    }

    // A NEW request with no relink. GL says the program still captures v_captured.
    const char* other = "v_other";
    TransformFeedbackVaryings(vs, 1, &other, GL_INTERLEAVED_ATTRIBS);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    // Force a composite rebuild through something entirely unrelated to the capture list: a new
    // fragment stage program moves that slot's lifetime id, so the cache signature changes.
    const GLuint fs2 = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs2);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    {
        const auto composite = DrawProgram();
        ASSERT_NE(composite, nullptr);
        EXPECT_TRUE(composite->GetLinkStatus()) << "the composite must still link";
        EXPECT_EQ(CompositeCaptureNames(*composite), (Vector<String>{"v_captured"}))
            << "an unlinked request must not reach the composite";
    }

    // Relinking the stage program IS what makes the new request take effect - and the composite
    // follows, because the relink moves the link version the cache keys on.
    LinkProgram(vs);
    {
        const auto composite = DrawProgram();
        ASSERT_NE(composite, nullptr);
        EXPECT_EQ(CompositeCaptureNames(*composite), (Vector<String>{"v_other"}));
    }

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}

// Transform feedback captures the output of the LAST vertex-processing stage (GL 4.6 core
// 11.1.2.1) - the last stage that EXISTS, not the last one that happens to carry a capture list.
// Falling through a geometry stage with no request and installing the vertex stage's list instead
// made the two halves disagree: this loop picks whose list, the link task resolves those names
// against the geometry intermediate. Either it captures where GL says it must not, or the
// composite fails to link and every draw through the pipeline reports GL_INVALID_OPERATION.
TEST_F(ProgramPipelineCompositeTest, CompositeCaptureStageIsTheLastVertexProcessingStageThatExists) {
    const GLuint vs = CreateProgram();
    {
        const GLuint shader = CreateShader(GL_VERTEX_SHADER);
        ShaderSource(shader, 1, &kCaptureVs, nullptr);
        CompileShader(shader);
        ProgramParameteri(vs, GL_PROGRAM_SEPARABLE, GL_TRUE);
        AttachShader(vs, shader);
        const char* captured = "v_captured";
        TransformFeedbackVaryings(vs, 1, &captured, GL_INTERLEAVED_ATTRIBS);
        LinkProgram(vs);
        GLint linked = GL_FALSE;
        GetProgramiv(vs, GL_LINK_STATUS, &linked);
        ASSERT_EQ(linked, GL_TRUE);
    }
    // The geometry program was never given a capture list, and "v_captured" is not one of its
    // outputs - so a composite seeded from the VERTEX program's list cannot resolve it.
    const GLuint gs = MakeSeparableProgram(GL_GEOMETRY_SHADER, kPassthroughGs);
    const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs);

    GLuint pipeline = 0;
    GenProgramPipelines(1, &pipeline);
    BindProgramPipeline(pipeline);
    UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
    UseProgramStages(pipeline, GL_GEOMETRY_SHADER_BIT, gs);
    UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
    ASSERT_EQ(GetError(), GL_NO_ERROR);

    const auto composite = DrawProgram();
    ASSERT_NE(composite, nullptr);
    EXPECT_TRUE(composite->GetLinkStatus())
        << "the geometry stage is the capture stage and has no capture list, so the composite links "
           "with none - it must not inherit the vertex stage's and fail resolving it";
    EXPECT_EQ(composite->GetTransformFeedbackVaryingCount(), 0u)
        << "the capture stage is the geometry program, which declared nothing to capture";

    BindProgramPipeline(0);
    DeleteProgramPipelines(1, &pipeline);
}
