#ifndef CLAY_RENDERER_GLES3_H
#define CLAY_RENDERER_GLES3_H

// There may be custom header customizations, very client specific
// let client indicate that they manage headers by setting GLSL_VERSION
#ifndef GLSL_VERSION
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#include <emscripten/html5.h>
#include <GLES3/gl3.h>
#define GLSL_VERSION "#version 300 es"
#else
// Only apple computers now sorry
// That means it is not really GLES3 but desktop OpenGL 3
// Luckily, it is compatible with GLES3
#include <OpenGL/gl3.h>
#define GLSL_VERSION "#version 330 core"
#endif
#endif

#define MAX_IMAGES 4
#define MAX_FONTS 4

/*
 * Instanced rendering for Rects/Images/Borders
 * will use this data
 * Note, it needs to be padded to 4 floats
 * Draws:
 * - One rectangular with possibly rounded corner
 * - And possibly with a hole inside (with rounded edges too, if corners are rounded)
 * - It could also draw a picture with alsoe rounded corner
 */
typedef struct RectInstance
{
    float x, y, w, h;         // 4 Draw where on screen
    float u0, v0, u1, v1;     // 4 Atlas region
    float r, g, b, a;         // 4 Color
    float radiusTL, radiusTR; // 2 Corner rounding
    float radiusBL, radiusBR; // 2
    float borderL, borderR;   // 2 Border widths
    float borderT, borderB;   // 2
    float texToUse;           // 1 Texture atlas to take an image from (1-4)
    float pad[3];             // 3
} RectInstance;

/*
 * Struct for glyph instanced rendering
 * Each glyph consists of 6 vertexes (to make 2 triangle of a quad)
 */
typedef struct GlyphVtx
{
    float x, y;         // To draw Where
    float u, v;         // To draw What
    float r, g, b, a;   // Text color
    float atlasTexUnit; // Shader will have all samples loaded but this will point which to use
    float pad[3];       // 3
} GlyphVtx;

typedef struct Gles3_GlyphVtxArray
{
    GlyphVtx *instData;
    int capacity;
    int count;
} Gles3_GlyphVtxArray;

typedef struct Gles3_QuadInstanceArray
{
    RectInstance *instData; // packed per-instance floats
    int capacity;           // how many instances it can hold
    int count;              // how many instances does it actually hold
} Gles3_QuadInstanceArray;

typedef struct Gles3_ImageConfig
{
    int textureToUse;
    float u0, v0;
    float u1, v1;
} Gles3_ImageConfig;

#ifndef CLAY_RENDERER_GLES3_IMPLEMENTATION
typedef struct Gles3_Renderer Gles3_Renderer;
#endif

#ifdef CLAY_RENDERER_GLES3_IMPLEMENTATION

#include <math.h>
#include <clay.h>
#include <stdlib.h>

#include "clay_renderer_gles3.h"

enum
{
    ATTR_QUAD_POS = 0,
    ATTR_QUAD_RECT = 1,
    ATTR_QUAD_COLOR = 2,
    ATTR_QUAD_UV = 3,
    ATTR_QUAD_RAD = 4,
    ATTR_QUAD_BORDER = 5,
    ATTR_QUAD_TEX = 6,
};

enum
{
    ATTR_GLYPH_POS = 0,
    ATTR_GLYPH_UV = 1,
    ATTR_GLYPH_COLOR = 2,
    ATTR_GLYPH_TEX = 3,
};

/*
 * rendering
 */

const char *GLES3_QUAD_VERTEX_SHADER =
    GLSL_VERSION
    "\n"
    "precision mediump float;\n"
    "layout(location = 0) in vec2 aPos;        // unit quad (0..1)\n"
    "layout(location = 1) in vec4 aRect;       // x,y,w,h (pixels)\n"
    "layout(location = 3) in vec4 aUV;         // u0,v0,u1,v1\n"
    "layout(location = 2) in vec4 aColor;      // rgba\n"
    "layout(location = 4) in vec4 aCornerRadii;\n"
    "layout(location = 5) in vec4 aBorderWidths;\n"
    "layout(location = 6) in float aTexSlot;\n"
    "uniform vec2 uScreen; // screen size in pixels\n"
    "out vec2 vPos;\n"
    "out vec4 vRect;\n"
    "out vec4 vColor;\n"
    "out vec2 vUV;\n"
    "out vec4 vCornerRadii;\n"
    "out vec4 vBorderWidths;\n"
    "out float vTexSlot;\n"
    "void main() {\n"
    "    vec2 pos = vec2(aPos.x * aRect.z + aRect.x, aPos.y * aRect.w + aRect.y);\n"
    "    vec2 ndc = pos / uScreen * 2.0 - 1.0; // ndc.y increases up; pos y increases down (we will inve\n"
    "    ndc.y = -ndc.y;\n"
    "    gl_Position = vec4(ndc, 0.0, 1.0);\n"
    "    vPos = aPos;\n"
    "    vRect = aRect;\n"
    "    vColor = aColor;\n"
    "    vUV = mix(aUV.xy, aUV.zw, aPos);\n"
    "    vCornerRadii = aCornerRadii;\n"
    "    vBorderWidths = aBorderWidths;\n"
    "    vTexSlot = aTexSlot;\n"
    "}\n";

const char *GLES3_QUAD_FRAGMENT_SHADER =
    GLSL_VERSION
    "\n"
    "precision mediump float;\n"
    "in vec2 vPos;\n"
    "in vec4 vRect;\n"
    "in vec4 vColor;\n"
    "in vec2 vUV;\n"
    "in vec4 vCornerRadii;\n"
    "in vec4 vBorderWidths;\n"
    "in float vTexSlot;\n"
    "uniform sampler2D uTex0;\n"
    "uniform sampler2D uTex1;\n"
    "uniform sampler2D uTex2;\n"
    "uniform sampler2D uTex3;\n"
    "out vec4 frag;\n"
    "void main() {\n"
    "    // Pixel coordinates in pixel space\n"
    "    vec2 pix = vRect.xy + vPos * vRect.zw;\n"
    "    float x0 = vRect.x;\n"
    "    float y0 = vRect.y;\n"
    "    float w  = vRect.z;\n"
    "    float h  = vRect.w;\n"
    "    // Local position inside the rectangle (0..w, 0..h)\n"
    "    vec2 local = pix - vec2(x0, y0);\n"
    "    // Original corner radii\n"
    "    float tl = vCornerRadii.x;\n"
    "    float tr = vCornerRadii.y;\n"
    "    float bl = vCornerRadii.z;\n"
    "    float br = vCornerRadii.w;\n"
    "    // Border thicknesses\n"
    "    float L = vBorderWidths.x;\n"
    "    float R = vBorderWidths.y;\n"
    "    float T = vBorderWidths.z;\n"
    "    float B = vBorderWidths.w;\n"
    "    bool CLAY_BORDERS_ARE_INSET = true; // it is true\n"
    "    bool isBorder = (L > 0.0 || R > 0.0 || T > 0.0 || B > 0.0);\n"
    "    float outerAlpha = 1.0;\n"
    "    // If it is not a border but rect or image, then it only has outer border what is provided\n"
    "    // Otherwise it increases the outter border, but the provided borde is the ineer border\n"
    "    // I think is better not increase rounding radius when that radius is smaller than border thickness\n"
    "    float outter_tl;\n"
    "    float outter_tr;\n"
    "    float outter_bl;\n"
    "    float outter_br;\n"
    "    if (CLAY_BORDERS_ARE_INSET) {\n"
    "        // Actural behaviour\n"
    "        outter_tl = tl;\n"
    "        outter_tr = tr;\n"
    "        outter_bl = bl;\n"
    "        outter_br = br;\n"
    "        tl = (tl > min(T, L)) ? tl - min(T, L) : tl;\n"
    "        tr = (tr > min(T, R)) ? tr - min(T, R) : tr;\n"
    "        bl = (bl > min(B, L)) ? bl - min(B, L) : bl;\n"
    "        br = (br > min(B, R)) ? br - min(B, R) : br;\n"
    "    } else {\n"
    "        // Hypothetical behaviour\n"
    "        outter_tl = (tl > min(T, L)) ? tl + min(T, L) : tl;\n"
    "        outter_tr = (tr > min(T, R)) ? tr + min(T, R) : tr;\n"
    "        outter_bl = (bl > min(B, L)) ? bl + min(B, L) : bl;\n"
    "        outter_br = (br > min(B, R)) ? br + min(B, R) : br;\n"
    "    }\n"
    "    if (outter_tl > 0.0 && local.x < outter_tl && local.y < outter_tl)\n"
    "        outerAlpha = step(length(local - vec2(outter_tl, outter_tl)), outter_tl);\n"
    "    if (outter_tr > 0.0 && local.x > w - outter_tr && local.y < outter_tr)\n"
    "        outerAlpha *= step(length(local - vec2(w - outter_tr, outter_tr)), outter_tr);\n"
    "    if (outter_bl > 0.0 && local.x < outter_bl && local.y > h - outter_bl)\n"
    "        outerAlpha *= step(length(local - vec2(outter_bl, h - outter_bl)), outter_bl);\n"
    "    if (outter_br > 0.0 && local.x > w - outter_br && local.y > h - outter_br)\n"
    "        outerAlpha *= step(length(local - vec2(w - outter_br, h - outter_br)), outter_br);\n"
    "    if (outerAlpha < 0.5)\n"
    "        discard;\n"
    "    // -------- Border logic --------\n"
    "    if (isBorder) {\n"
    "        float iw = w - L - R;\n"
    "        float ih = h - T - B;\n"
    "        vec2 innerLocal = local - vec2(L, T);\n"
    "        // Check if pixel is inside inner rounded rect\n"
    "        bool insideInner = true;\n"
    "        if (tl > 0.0 && innerLocal.x < tl && innerLocal.y < tl)\n"
    "            insideInner = (length(innerLocal - vec2(tl, tl)) <= tl);\n"
    "        if (tr > 0.0 && innerLocal.x > iw - tr && innerLocal.y < tr)\n"
    "            insideInner = insideInner && (length(innerLocal - vec2(iw - tr, tr)) <= tr);\n"
    "        // Bottom-left\n"
    "        if (bl> 0.0 && innerLocal.x < bl && innerLocal.y > ih - bl) \n"
    "            insideInner = insideInner && (length(innerLocal - vec2(bl, ih - bl)) <= bl);\n"
    "        // Bottom-right\n"
    "        if (br > 0.0 && innerLocal.x > iw - br && innerLocal.y > ih - br)\n"
    "            insideInner = insideInner && (length(innerLocal - vec2(iw - br, ih - br)) <= br);\n"
    "        // Discard pixels inside inner rounded rect\n"
    "        if (insideInner && innerLocal.x >= 0.0 && innerLocal.x <= iw && innerLocal.y >= 0.0 && innerLocal.y <= ih)\n"
    "            discard;\n"
    "        frag = vColor;\n"
    "        return;\n"
    "    }\n"
    "    // -------- Non-border rectangle or image --------\n"
    "    if (vTexSlot < 0.0) {\n"
    "        frag = vColor;\n"
    "    } else {\n"
    "        int slot = int(vTexSlot + 0.5);\n"
    "        if (slot == 0) frag = texture(uTex0, vUV);\n"
    "        if (slot == 1) frag = texture(uTex1, vUV);\n"
    "        if (slot == 2) frag = texture(uTex2, vUV);\n"
    "        if (slot == 3) frag = texture(uTex3, vUV);\n"
    "    }\n"
    "}\n";

const char *GLES3_TEXT_VERTEX_SHADER =
    GLSL_VERSION
    "\n"
    "precision mediump float;\n"
    "layout(location = 0) in vec2 aPos;\n"
    "layout(location = 1) in vec2 aUV;\n"
    "layout(location = 2) in vec4 aColor;\n"
    "layout(location = 3) in float aTexSlot;\n"
    "uniform vec2 uScreen;\n"
    "out vec2 vUV;\n"
    "out vec4 vColor;\n"
    "out float vTexSlot;\n"
    "void main() {\n"
    "    vec2 ndc = (aPos / uScreen) * 2.0 - 1.0;\n"
    "    gl_Position = vec4(ndc * vec2(1.0, -1.0), 0.0, 1.0);\n"
    "    vUV = aUV;\n"
    "    vColor = aColor;\n"
    "    vTexSlot = aTexSlot;\n"
    "}\n";

const char *GLES3_TEXT_FRAGMENT_SHADER =
    GLSL_VERSION
    "\n"
    "precision mediump float;\n"
    "in vec2 vUV;\n"
    "in vec4 vColor;\n"
    "in float vTexSlot;\n"
    "uniform sampler2D uTex0;\n"
    "uniform sampler2D uTex1;\n"
    "uniform sampler2D uTex2;\n"
    "uniform sampler2D uTex3;\n"
    "out vec4 fragColor;\n"
    "void main() {\n"
    "    int slot = int(vTexSlot + 0.5);\n"
    "    float coverage;\n"
    "    if (slot == 0) coverage = texture(uTex0, vUV).r;\n"
    "    if (slot == 1) coverage = texture(uTex1, vUV).r;\n"
    "    if (slot == 2) coverage = texture(uTex2, vUV).r;\n"
    "    if (slot == 3) coverage = texture(uTex3, vUV).r;\n"
    "    fragColor = vec4(vColor.rgb, vColor.a * coverage);\n"
    "} \n";

/**
 * This renderer accumulates all quads and glyphs of every draw coommand
 * in their array, and flushes them in just 2 instanced draw calls to OpenGL
 */
typedef struct Gles3_Renderer
{
    Clay_Arena clayMemory;

    // It is super important keep track on the performance of this renderer:
    uint64_t totalDrawCallsToOpenGl;

    float screenWidth;
    float screenHeight;

    /* Quads rendering */
    GLuint quadVAO;
    GLuint quadVBO;
    GLuint quadInstanceVBO;
    GLuint quadShaderId;
    GLuint imageTextures[MAX_IMAGES];
    Gles3_QuadInstanceArray quadInstanceArray; // Each instance is one quad

    /* Fonts rendering */
    GLuint textVAO;
    GLuint textVBO;
    GLuint textShader;
    GLuint fontTextures[MAX_FONTS];
    Gles3_GlyphVtxArray glyphVtxArray; // Instance data: every vertex is an element,
                                       // 6 elements per each instance

    // Text renderer is delegated to external function, which is supposed
    // to add glyph data based on passed render text command
    void (*renderTextFunction)(
        Clay_RenderCommand *cmd,    // Will be always of CLAY_RENDER_COMMAND_TYPE_TEXT
        Gles3_GlyphVtxArray *accum, // 6 vertices need to be added to this array
        void *userData              // Fonts pallete
    );
} Gles3_Renderer;

static GLuint Gles3__CompileShader(GLenum type, const char *source)
{
    GLuint shader = glCreateShader(type);
    glShaderSource(shader, 1, &source, NULL);
    glCompileShader(shader);

    GLint success;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        char infoLog[512];
        glGetShaderInfoLog(shader, 512, NULL, infoLog);

        printf("ERROR::SHADER::COMPILATION_FAILED\n");
        printf("SHADER SOURCE:\n%s\n", source);
        printf("SHADER TYPE: ");
        if (type == GL_VERTEX_SHADER)
            printf("Vertex Shader");
        else if (type == GL_FRAGMENT_SHADER)
            printf("Fragment Shader");
        else
            printf("Unknown");
        printf("\nSHADER COMPILATION ERROR:\n%s\n", infoLog);
        abort();
    }
    return shader;
}

GLuint Gles3__CreateShaderProgram(
    const char *vertexShaderSource,
    const char *fragmentShaderSource)
{
    GLuint vertexShader =
        Gles3__CompileShader(GL_VERTEX_SHADER, vertexShaderSource);
    GLuint fragmentShader =
        Gles3__CompileShader(GL_FRAGMENT_SHADER, fragmentShaderSource);

    GLuint shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);

    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    return shaderProgram;
}

void Gles3_Initialize(Gles3_Renderer *renderer, int maxInstances)
{
    renderer->totalDrawCallsToOpenGl = 0;
    // compile shader
    renderer->quadShaderId = Gles3__CreateShaderProgram(
        GLES3_QUAD_VERTEX_SHADER, GLES3_QUAD_FRAGMENT_SHADER);

    glUseProgram(renderer->quadShaderId);
    glUniform1i(glGetUniformLocation(renderer->quadShaderId, "uTex0"), 0);
    glUniform1i(glGetUniformLocation(renderer->quadShaderId, "uTex1"), 1);
    glUniform1i(glGetUniformLocation(renderer->quadShaderId, "uTex2"), 2);
    glUniform1i(glGetUniformLocation(renderer->quadShaderId, "uTex3"), 3);

    // create unit quad VBO (0..1)
    const float quadVerts[8] = {0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f};
    glGenVertexArrays(1, &renderer->quadVAO);
    glBindVertexArray(renderer->quadVAO);

    glGenBuffers(1, &renderer->quadVBO);
    glBindBuffer(GL_ARRAY_BUFFER, renderer->quadVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW);

    // attribute 0: aPos (vec2), per-vertex
    glEnableVertexAttribArray(ATTR_QUAD_POS);
    glVertexAttribPointer(ATTR_QUAD_POS, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void *)0);
    glVertexAttribDivisor(ATTR_QUAD_POS, 0);

    // create instance buffer big enough
    Gles3_QuadInstanceArray *quads = &renderer->quadInstanceArray;
    quads->capacity = maxInstances;
    quads->instData =
        (RectInstance *)malloc(sizeof(RectInstance) * quads->capacity);
    quads->count = 0;

    glGenBuffers(1, &renderer->quadInstanceVBO);
    glBindBuffer(GL_ARRAY_BUFFER, renderer->quadInstanceVBO);
    glBufferData(GL_ARRAY_BUFFER,
                 sizeof(RectInstance) * quads->capacity,
                 NULL,
                 GL_DYNAMIC_DRAW);

    // set up instance attributes
    GLsizei stride = sizeof(RectInstance);

    glEnableVertexAttribArray(ATTR_QUAD_RECT);
    glVertexAttribPointer(ATTR_QUAD_RECT, 4, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, x));
    glVertexAttribDivisor(ATTR_QUAD_RECT, 1);

    glEnableVertexAttribArray(ATTR_QUAD_COLOR);
    glVertexAttribPointer(ATTR_QUAD_COLOR, 4, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, r));
    glVertexAttribDivisor(ATTR_QUAD_COLOR, 1);

    glEnableVertexAttribArray(ATTR_QUAD_UV);
    glVertexAttribPointer(ATTR_QUAD_UV, 4, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, u0));
    glVertexAttribDivisor(ATTR_QUAD_UV, 1);

    glEnableVertexAttribArray(ATTR_QUAD_RAD);
    glVertexAttribPointer(ATTR_QUAD_RAD, 4, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, radiusTL));
    glVertexAttribDivisor(ATTR_QUAD_RAD, 1);

    glEnableVertexAttribArray(ATTR_QUAD_BORDER);
    glVertexAttribPointer(ATTR_QUAD_BORDER, 4, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, borderL));
    glVertexAttribDivisor(ATTR_QUAD_BORDER, 1);

    glEnableVertexAttribArray(ATTR_QUAD_TEX);
    glVertexAttribPointer(ATTR_QUAD_TEX, 1, GL_FLOAT, GL_FALSE,
                          stride, (void *)offsetof(RectInstance, texToUse));
    glVertexAttribDivisor(ATTR_QUAD_TEX, 1);

    glBindVertexArray(1);
    glBindBuffer(GL_ARRAY_BUFFER, 0);

    // Ok now we will initialize text!
    Gles3_GlyphVtxArray *gVerts = &renderer->glyphVtxArray;

    // configure capacity
    gVerts->capacity = maxInstances;
    gVerts->count = 0;

    // allocate CPU-side vertex buffer: 6 vertices per glyph
    gVerts->instData = (GlyphVtx *)malloc(sizeof(GlyphVtx) * 6 * gVerts->capacity);
    if (!gVerts->instData)
    {
        fprintf(stderr, "Failed to allocate glyph_vertices\n");
        gVerts->capacity = 0;
    }

    // create VAO/VBO for text rendering
    glGenVertexArrays(1, &renderer->textVAO);
    glBindVertexArray(renderer->textVAO);

    glGenBuffers(1, &renderer->textVBO);
    glBindBuffer(GL_ARRAY_BUFFER, renderer->textVBO);
    glBufferData(GL_ARRAY_BUFFER,
                 sizeof(GlyphVtx) * 6 * gVerts->capacity,
                 NULL,
                 GL_DYNAMIC_DRAW);

    GLsizei gv_stride = sizeof(GlyphVtx);

    glEnableVertexAttribArray(ATTR_GLYPH_POS);
    glVertexAttribPointer(ATTR_GLYPH_POS, 2, GL_FLOAT, GL_FALSE, gv_stride, (void *)(offsetof(GlyphVtx, x)));

    glEnableVertexAttribArray(ATTR_GLYPH_UV);
    glVertexAttribPointer(ATTR_GLYPH_UV, 2, GL_FLOAT, GL_FALSE, gv_stride, (void *)(offsetof(GlyphVtx, u)));

    glEnableVertexAttribArray(ATTR_GLYPH_COLOR);
    glVertexAttribPointer(ATTR_GLYPH_COLOR, 4, GL_FLOAT, GL_FALSE, gv_stride, (void *)(offsetof(GlyphVtx, r)));

    glEnableVertexAttribArray(ATTR_GLYPH_TEX);
    glVertexAttribPointer(ATTR_GLYPH_TEX, 1, GL_FLOAT, GL_FALSE, gv_stride, (void *)(offsetof(GlyphVtx, atlasTexUnit)));

    glBindVertexArray(0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);

    renderer->textShader = Gles3__CreateShaderProgram(
        GLES3_TEXT_VERTEX_SHADER, GLES3_TEXT_FRAGMENT_SHADER);
    glUseProgram(renderer->textShader);

    // Link sampler uniforms in the text shader to the correct texture units.
    // Each uniform tells the shader which unit to read from.
    glUniform1i(glGetUniformLocation(renderer->textShader, "uTex0"), 0);
    glUniform1i(glGetUniformLocation(renderer->textShader, "uTex1"), 1);
    glUniform1i(glGetUniformLocation(renderer->textShader, "uTex2"), 2);
    glUniform1i(glGetUniformLocation(renderer->textShader, "uTex3"), 3);
}

void Gles3_SetRenderTextFunction(
    Gles3_Renderer *renderer,
    void (*renderTextFunction)(
        Clay_RenderCommand *cmd, Gles3_GlyphVtxArray *accum, void *userData),
    void *userData)
{
    renderer->renderTextFunction = renderTextFunction;
}

void Gles3_Render(
    Gles3_Renderer *renderer,
    Clay_RenderCommandArray cmds,
    void *userData // eg. fonts
)
{
    Clay_Dimensions layoutDimensions = Clay_GetCurrentContext()->layoutDimensions;
    renderer->screenWidth = layoutDimensions.width;
    renderer->screenHeight = layoutDimensions.height;

    Gles3_QuadInstanceArray *quads = &renderer->quadInstanceArray;
    Gles3_GlyphVtxArray *gVerts = &renderer->glyphVtxArray;

    gVerts->count = 0;

    for (int i = 0; i < cmds.length; i++)
    {
        Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(&cmds, i);
        Clay_BoundingBox boundingBox = (Clay_BoundingBox){
            .x = roundf(cmd->boundingBox.x),
            .y = roundf(cmd->boundingBox.y),
            .width = roundf(cmd->boundingBox.width),
            .height = roundf(cmd->boundingBox.height),
        };

        bool scissorChanged = false;
        switch (cmd->commandType)
        {
        case CLAY_RENDER_COMMAND_TYPE_TEXT:
        {
            renderer->renderTextFunction(
                cmd,
                &renderer->glyphVtxArray,
                userData);
            break;
        }
        case CLAY_RENDER_COMMAND_TYPE_RECTANGLE:
        case CLAY_RENDER_COMMAND_TYPE_IMAGE:
        {
            Clay_RectangleRenderData *config = &cmd->renderData.rectangle;
            Clay_Color c = config->backgroundColor;

            // Convert to float 0..1
            float rf = c.r / 255.0f;
            float gf = c.g / 255.0f;
            float bf = c.b / 255.0f;
            float af = c.a / 255.0f;

            bool isImage = cmd->commandType == CLAY_RENDER_COMMAND_TYPE_IMAGE;

            // Ensure we don't overflow the capacity
            if (quads->count >= quads->capacity)
            {
                printf("Clay renderer: instance overflow!\n");
                break;
            }

            int idx = quads->count;
            RectInstance *dst = &quads->instData[idx];
            dst->x = boundingBox.x;
            dst->y = boundingBox.y;
            dst->w = boundingBox.width;
            dst->h = boundingBox.height;

            if (isImage)
            {
                Gles3_ImageConfig *imgConf = (Gles3_ImageConfig *)cmd->renderData.image.imageData;
                dst->u0 = imgConf->u0;
                dst->v0 = imgConf->v0;
                dst->u1 = imgConf->u1;
                dst->v1 = imgConf->v1;
                dst->texToUse = (float)imgConf->textureToUse;
            }
            else
            {
                dst->u0 = dst->v0 = 0.0f;
                dst->u1 = dst->v1 = 1.0f;
                dst->texToUse = -1.0f; // This means no image, use albedo color
            }

            // colour
            dst->r = rf;
            dst->g = gf;
            dst->b = bf;
            dst->a = af;

            // corner radii
            Clay_CornerRadius r = config->cornerRadius;
            dst->radiusTL = r.topLeft;
            dst->radiusTR = r.topRight;
            dst->radiusBL = r.bottomLeft;
            dst->radiusBR = r.bottomRight;

            dst->borderT = 0.0f;
            dst->borderR = 0.0f;
            dst->borderB = 0.0f;
            dst->borderL = 0.0f;

            quads->count++;
            break;
        }
        case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
        {
            scissorChanged = true;
            break;
        }
        case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END:
        {
            scissorChanged = true;
            break;
        }
        case CLAY_RENDER_COMMAND_TYPE_BORDER:
        {
            Clay_BorderRenderData *br = &cmd->renderData.border;

            float rf = br->color.r / 255.0f;
            float gf = br->color.g / 255.0f;
            float bf = br->color.b / 255.0f;
            float af = br->color.a / 255.0f;

            float x = boundingBox.x;
            float y = boundingBox.y;
            float w = boundingBox.width;
            float h = boundingBox.height;

            float top = br->width.top;
            float bottom = br->width.bottom;
            float left = br->width.left;
            float right = br->width.right;

            int idx = quads->count;
            RectInstance *dst = &quads->instData[idx];

            dst->x = x - left;
            dst->y = y - top;
            dst->w = w + right;
            dst->h = h + bottom;

            dst->borderB = bottom;
            dst->borderL = left;
            dst->borderT = top;
            dst->borderR = right;

            // Clay borders are inset, but adding support to outset borders
            // Is as easy as this + some minor changes in shader too
            bool CLAY_BORDERS_ARE_INSET = true;
            if (CLAY_BORDERS_ARE_INSET)
            {
                // Normal behaviour
                dst->x = x;
                dst->y = y;
                dst->w = w;
                dst->h = h;
            }
            else
            {
                // Hypotethical behaviour, if the borders were outside
                dst->x = x - left;
                dst->y = y - top;
                dst->w = w + left + right;
                dst->h = h + top + bottom;
            }

            dst->u0 = 0.0f;
            dst->v0 = 0.0f;
            dst->u1 = 1.0f;
            dst->v1 = 1.0f;

            dst->r = rf;
            dst->g = gf;
            dst->b = bf;
            dst->a = af;

            dst->radiusTL = br->cornerRadius.topLeft;
            dst->radiusTR = br->cornerRadius.topRight;
            dst->radiusBR = br->cornerRadius.bottomRight;
            dst->radiusBL = br->cornerRadius.bottomLeft;

            dst->texToUse = -1.0f;

            quads->count++;
            break;
        }

        case CLAY_RENDER_COMMAND_TYPE_CUSTOM:
        {
            // printf("Unhandled clay cmd: custom\n");
            break;
        }
        default:
        {
            printf("Error: unhandled render command\n");
            exit(1);
        }
        }

        // Flush draw calls if scissors about to change in this iteration
        if (i == cmds.length - 1 || scissorChanged)
        {
            scissorChanged = false;
            // Render Recatangles and Images
            if (quads->count > 0)
            {
                glUseProgram(renderer->quadShaderId);

                glActiveTexture(GL_TEXTURE0);
                glBindTexture(GL_TEXTURE_2D, renderer->imageTextures[0]);
                glActiveTexture(GL_TEXTURE1);
                glBindTexture(GL_TEXTURE_2D, renderer->imageTextures[1]);
                glActiveTexture(GL_TEXTURE2);
                glBindTexture(GL_TEXTURE_2D, renderer->imageTextures[2]);
                glActiveTexture(GL_TEXTURE3);
                glBindTexture(GL_TEXTURE_2D, renderer->imageTextures[3]);

                // set uniforms
                GLint locScreen = glGetUniformLocation(renderer->quadShaderId, "uScreen");
                glUniform2f(locScreen,
                            (float)renderer->screenWidth,
                            (float)renderer->screenHeight);

                glBindVertexArray(renderer->quadVAO);

                // upload all instances at once
                glBindBuffer(GL_ARRAY_BUFFER, renderer->quadInstanceVBO);

                // rectangles are solid colour — disable atlas use
                glBufferSubData(GL_ARRAY_BUFFER,
                                0,
                                quads->count * sizeof(RectInstance),
                                quads->instData);

                // draw unit quad (4 verts) instanced
                glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, quads->count);
                renderer->totalDrawCallsToOpenGl += 1;

                glBindVertexArray(0);
                glUseProgram(0);
            }
            // Clrear instance arrays, as they were flushed to their render calls
            quads->count = 0;

            // Text rendering
            if (renderer->glyphVtxArray.count > 0)
            {
                glUseProgram(renderer->textShader);

                glActiveTexture(GL_TEXTURE0);
                glBindTexture(GL_TEXTURE_2D, renderer->fontTextures[0]);

                glActiveTexture(GL_TEXTURE1);
                glBindTexture(GL_TEXTURE_2D, renderer->fontTextures[1]);

                glActiveTexture(GL_TEXTURE2);
                glBindTexture(GL_TEXTURE_2D, renderer->fontTextures[2]);

                glActiveTexture(GL_TEXTURE3);
                glBindTexture(GL_TEXTURE_2D, renderer->fontTextures[3]);

                GLint uScreenLoc = glGetUniformLocation(renderer->textShader, "uScreen");
                glUniform2f(uScreenLoc, renderer->screenWidth, renderer->screenHeight);

                glBindVertexArray(renderer->textVAO);
                glBindBuffer(GL_ARRAY_BUFFER, renderer->textVBO);

                glBufferSubData(
                    GL_ARRAY_BUFFER,
                    0,
                    sizeof(struct GlyphVtx) * 6 * gVerts->count,
                    renderer->glyphVtxArray.instData);

                glDrawArrays(GL_TRIANGLES, 0, renderer->glyphVtxArray.count * 6);
                renderer->totalDrawCallsToOpenGl += 1;

                glBindVertexArray(0);
                glBindTexture(GL_TEXTURE_2D, 0);
            }
            renderer->glyphVtxArray.count = 0;

            if (cmd->commandType == CLAY_RENDER_COMMAND_TYPE_SCISSOR_START)
            {
                Clay_BoundingBox bb = cmd->boundingBox;
                GLint x = (GLint)bb.x;
                GLint y = (GLint)(renderer->screenHeight - (bb.y + bb.height));
                GLsizei w = (GLsizei)bb.width;
                GLsizei h = (GLsizei)bb.height;

                glEnable(GL_SCISSOR_TEST);
                glScissor(x, y, w, h);
            }
            else
            {
                glDisable(GL_SCISSOR_TEST);
            }
        }
    }
}
#endif
#endif


package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"slices"
	"strings"

	"github.com/TecharoHQ/anubis/lib/config"

	"sigs.k8s.io/yaml"
)

var (
	inputFile     = flag.String("input", "", "path to robots.txt file (use - for stdin)")
	outputFile    = flag.String("output", "", "output file path (use - for stdout, defaults to stdout)")
	outputFormat  = flag.String("format", "yaml", "output format: yaml or json")
	baseAction    = flag.String("action", "CHALLENGE", "default action for disallowed paths: ALLOW, DENY, CHALLENGE, WEIGH")
	crawlDelay    = flag.Int("crawl-delay-weight", 0, "if > 0, add weight adjustment for crawl-delay (difficulty adjustment)")
	policyName    = flag.String("name", "robots-txt-policy", "name for the generated policy")
	userAgentDeny = flag.String("deny-user-agents", "DENY", "action for specifically blocked user agents: DENY, CHALLENGE")
	helpFlag      = flag.Bool("help", false, "show help")
)

type RobotsRule struct {
	UserAgents  []string
	Disallows   []string
	Allows      []string
	CrawlDelay  int
	IsBlacklist bool // true if this is a specifically denied user agent
}

type AnubisRule struct {
	Expression *config.ExpressionOrList `yaml:"expression,omitempty" json:"expression,omitempty"`
	Challenge  *config.ChallengeRules   `yaml:"challenge,omitempty" json:"challenge,omitempty"`
	Weight     *config.Weight           `yaml:"weight,omitempty" json:"weight,omitempty"`
	Name       string                   `yaml:"name" json:"name"`
	Action     string                   `yaml:"action" json:"action"`
}

func init() {
	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "%s [options] -input <robots.txt>\n\n", os.Args[0])
		flag.PrintDefaults()
		fmt.Fprintln(os.Stderr, "\nExamples:")
		fmt.Fprintln(os.Stderr, "  # Convert local robots.txt file")
		fmt.Fprintln(os.Stderr, "  robots2policy -input robots.txt -output policy.yaml")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "  # Convert from URL")
		fmt.Fprintln(os.Stderr, "  robots2policy -input https://example.com/robots.txt -format json")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "  # Read from stdin, write to stdout")
		fmt.Fprintln(os.Stderr, "  curl https://example.com/robots.txt | robots2policy -input -")
		os.Exit(2)
	}
}

func main() {
	flag.Parse()

	if len(flag.Args()) > 0 || *helpFlag || *inputFile == "" {
		flag.Usage()
	}

	// Read robots.txt
	var input io.Reader
	if *inputFile == "-" {
		input = os.Stdin
	} else if strings.HasPrefix(*inputFile, "http://") || strings.HasPrefix(*inputFile, "https://") {
		resp, err := http.Get(*inputFile)
		if err != nil {
			log.Fatalf("failed to fetch robots.txt from URL: %v", err)
		}
		defer resp.Body.Close() //nolint:errcheck
		input = resp.Body
	} else {
		file, err := os.Open(*inputFile)
		if err != nil {
			log.Fatalf("failed to open input file: %v", err)
		}
		defer func() {
			if err := file.Close(); err != nil {
				log.Fatalf("can't close output file %s: %v", file.Name(), err)
			}
		}()
		input = file
	}

	// Parse robots.txt
	rules, err := parseRobotsTxt(input)
	if err != nil {
		log.Fatalf("failed to parse robots.txt: %v", err)
	}

	// Convert to Anubis rules
	anubisRules := convertToAnubisRules(rules)

	// Check if any rules were generated
	if len(anubisRules) == 0 {
		log.Fatal("no valid rules generated from robots.txt - file may be empty or contain no disallow directives")
	}

	// Generate output
	var output []byte
	switch strings.ToLower(*outputFormat) {
	case "yaml":
		output, err = yaml.Marshal(anubisRules)
	case "json":
		output, err = json.MarshalIndent(anubisRules, "", "  ")
	default:
		log.Fatalf("unsupported output format: %s (use yaml or json)", *outputFormat)
	}

	if err != nil {
		log.Fatalf("failed to marshal output: %v", err)
	}

	// Write output
	if *outputFile == "" || *outputFile == "-" {
		fmt.Print(string(output))
	} else {
		err = os.WriteFile(*outputFile, output, 0644)
		if err != nil {
			log.Fatalf("failed to write output file: %v", err)
		}
		fmt.Printf("Generated Anubis policy written to %s\n", *outputFile)
	}
}

func createRuleFromAccumulated(userAgents, disallows, allows []string, crawlDelay int) RobotsRule {
	rule := RobotsRule{
		UserAgents: make([]string, len(userAgents)),
		Disallows:  make([]string, len(disallows)),
		Allows:     make([]string, len(allows)),
		CrawlDelay: crawlDelay,
	}
	copy(rule.UserAgents, userAgents)
	copy(rule.Disallows, disallows)
	copy(rule.Allows, allows)
	return rule
}

func parseRobotsTxt(input io.Reader) ([]RobotsRule, error) {
	scanner := bufio.NewScanner(input)
	var rules []RobotsRule
	var currentUserAgents []string
	var currentDisallows []string
	var currentAllows []string
	var currentCrawlDelay int

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		// Skip empty lines and comments
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		// Split on first colon
		parts := strings.SplitN(line, ":", 2)
		if len(parts) != 2 {
			continue
		}

		directive := strings.TrimSpace(strings.ToLower(parts[0]))
		value := strings.TrimSpace(parts[1])

		switch directive {
		case "user-agent":
			// If we have accumulated rules with directives and encounter a new user-agent,
			// flush the current rules
			if len(currentUserAgents) > 0 && (len(currentDisallows) > 0 || len(currentAllows) > 0 || currentCrawlDelay > 0) {
				rule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay)
				rules = append(rules, rule)
				// Reset for next group
				currentUserAgents = nil
				currentDisallows = nil
				currentAllows = nil
				currentCrawlDelay = 0
			}
			currentUserAgents = append(currentUserAgents, value)

		case "disallow":
			if len(currentUserAgents) > 0 && value != "" {
				currentDisallows = append(currentDisallows, value)
			}

		case "allow":
			if len(currentUserAgents) > 0 && value != "" {
				currentAllows = append(currentAllows, value)
			}

		case "crawl-delay":
			if len(currentUserAgents) > 0 {
				if delay, err := parseIntSafe(value); err == nil {
					currentCrawlDelay = delay
				}
			}
		}
	}

	// Don't forget the last group of rules
	if len(currentUserAgents) > 0 {
		rule := createRuleFromAccumulated(currentUserAgents, currentDisallows, currentAllows, currentCrawlDelay)
		rules = append(rules, rule)
	}

	// Mark blacklisted user agents (those with "Disallow: /")
	for i := range rules {
		if slices.Contains(rules[i].Disallows, "/") {
			rules[i].IsBlacklist = true
		}
	}

	return rules, scanner.Err()
}

func parseIntSafe(s string) (int, error) {
	var result int
	_, err := fmt.Sscanf(s, "%d", &result)
	return result, err
}

func convertToAnubisRules(robotsRules []RobotsRule) []AnubisRule {
	var anubisRules []AnubisRule
	ruleCounter := 0

	// Process each robots rule individually
	for _, robotsRule := range robotsRules {
		userAgents := robotsRule.UserAgents

		// Handle crawl delay
		if robotsRule.CrawlDelay > 0 && *crawlDelay > 0 {
			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-crawl-delay-%d", *policyName, ruleCounter),
				Action: "WEIGH",
				Weight: &config.Weight{Adjust: *crawlDelay},
			}

			if len(userAgents) == 1 && userAgents[0] == "*" {
				rule.Expression = &config.ExpressionOrList{
					All: []string{"true"}, // Always applies
				}
			} else if len(userAgents) == 1 {
				rule.Expression = &config.ExpressionOrList{
					All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgents[0])},
				}
			} else {
				// Multiple user agents - use any block
				var expressions []string
				for _, ua := range userAgents {
					if ua == "*" {
						expressions = append(expressions, "true")
					} else {
						expressions = append(expressions, fmt.Sprintf("userAgent.contains(%q)", ua))
					}
				}
				rule.Expression = &config.ExpressionOrList{
					Any: expressions,
				}
			}
			anubisRules = append(anubisRules, rule)
		}

		// Handle blacklisted user agents
		if robotsRule.IsBlacklist {
			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-blacklist-%d", *policyName, ruleCounter),
				Action: *userAgentDeny,
			}

			if len(userAgents) == 1 {
				userAgent := userAgents[0]
				if userAgent == "*" {
					// This would block everything - convert to a weight adjustment instead
					rule.Name = fmt.Sprintf("%s-global-restriction-%d", *policyName, ruleCounter)
					rule.Action = "WEIGH"
					rule.Weight = &config.Weight{Adjust: 20} // Increase difficulty significantly
					rule.Expression = &config.ExpressionOrList{
						All: []string{"true"}, // Always applies
					}
				} else {
					rule.Expression = &config.ExpressionOrList{
						All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgent)},
					}
				}
			} else {
				// Multiple user agents - use any block
				var expressions []string
				for _, ua := range userAgents {
					if ua == "*" {
						expressions = append(expressions, "true")
					} else {
						expressions = append(expressions, fmt.Sprintf("userAgent.contains(%q)", ua))
					}
				}
				rule.Expression = &config.ExpressionOrList{
					Any: expressions,
				}
			}
			anubisRules = append(anubisRules, rule)
		}

		// Handle specific disallow rules
		for _, disallow := range robotsRule.Disallows {
			if disallow == "/" {
				continue // Already handled as blacklist above
			}

			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-disallow-%d", *policyName, ruleCounter),
				Action: *baseAction,
			}

			// Build CEL expression
			var conditions []string

			// Add user agent conditions
			if len(userAgents) == 1 && userAgents[0] == "*" {
				// Wildcard user agent - no user agent condition needed
			} else if len(userAgents) == 1 {
				conditions = append(conditions, fmt.Sprintf("userAgent.contains(%q)", userAgents[0]))
			} else {
				// For multiple user agents, we need to use a more complex expression
				// This is a limitation - we can't easily combine any for user agents with all for path
				// So we'll create separate rules for each user agent
				for _, ua := range userAgents {
					if ua == "*" {
						continue // Skip wildcard as it's handled separately
					}
					ruleCounter++
					subRule := AnubisRule{
						Name:   fmt.Sprintf("%s-disallow-%d", *policyName, ruleCounter),
						Action: *baseAction,
						Expression: &config.ExpressionOrList{
							All: []string{
								fmt.Sprintf("userAgent.contains(%q)", ua),
								buildPathCondition(disallow),
							},
						},
					}
					anubisRules = append(anubisRules, subRule)
				}
				continue
			}

			// Add path condition
			pathCondition := buildPathCondition(disallow)
			conditions = append(conditions, pathCondition)

			rule.Expression = &config.ExpressionOrList{
				All: conditions,
			}

			anubisRules = append(anubisRules, rule)
		}
	}

	return anubisRules
}

func buildPathCondition(robotsPath string) string {
	// Handle wildcards in robots.txt paths
	if strings.Contains(robotsPath, "*") || strings.Contains(robotsPath, "?") {
		// Convert robots.txt wildcards to regex
		regex := regexp.QuoteMeta(robotsPath)
		regex = strings.ReplaceAll(regex, `\*`, `.*`) // * becomes .*
		regex = strings.ReplaceAll(regex, `\?`, `.`)  // ? becomes .
		regex = "^" + regex
		return fmt.Sprintf("path.matches(%q)", regex)
	}

	// Simple prefix match for most cases
	return fmt.Sprintf("path.startsWith(%q)", robotsPath)
}


import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { Validator } from '../../src/core/validation/validator.js';
import {
  ScenarioSchema,
  RequirementSchema,
  SpecSchema,
  ChangeSchema,
  DeltaSchema
} from '../../src/core/schemas/index.js';

describe('Validation Schemas', () => {
  describe('ScenarioSchema', () => {
    it('should validate a valid scenario', () => {
      const scenario = {
        rawText: 'Given a user is logged in\nWhen they click logout\nThen they are redirected to login page',
      };

      const result = ScenarioSchema.safeParse(scenario);
      expect(result.success).toBe(true);
    });

    it('should reject scenario with empty text', () => {
      const scenario = {
        rawText: '',
      };

      const result = ScenarioSchema.safeParse(scenario);
      expect(result.success).toBe(false);
      if (!result.success) {
        expect(result.error.issues[0].message).toBe('Scenario text cannot be empty');
      }
    });
  });

  describe('RequirementSchema', () => {
    it('should validate a valid requirement', () => {
      const requirement = {
        text: 'The system SHALL provide user authentication',
        scenarios: [
          {
            rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
          },
        ],
      };

      const result = RequirementSchema.safeParse(requirement);
      expect(result.success).toBe(true);
    });

    it('no longer enforces SHALL or MUST at the schema level (moved to the validator)', () => {
      // SHALL/MUST body-keyword enforcement moved out of the Zod refine and into
      // Validator.applySpecRules so it can recover the requirement header and
      // emit the targeted body-keyword hint (#1156). The schema therefore accepts
      // a body without the keyword; the validator (exercised below) reports it.
      const requirement = {
        text: 'The system provides user authentication',
        scenarios: [
          {
            rawText: 'Given a user\nWhen they login\nThen authenticated',
          },
        ],
      };

      const result = RequirementSchema.safeParse(requirement);
      expect(result.success).toBe(true);
    });

    it('should reject requirement without scenarios', () => {
      const requirement = {
        text: 'The system SHALL provide user authentication',
        scenarios: [],
      };

      const result = RequirementSchema.safeParse(requirement);
      expect(result.success).toBe(false);
      if (!result.success) {
        expect(result.error.issues[0].message).toBe('Requirement must have at least one scenario');
      }
    });
  });

  describe('SpecSchema', () => {
    it('should validate a valid spec', () => {
      const spec = {
        name: 'user-auth',
        overview: 'This spec defines user authentication requirements',
        requirements: [
          {
            text: 'The system SHALL provide user authentication',
            scenarios: [
              {
                rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
              },
            ],
          },
        ],
      };

      const result = SpecSchema.safeParse(spec);
      expect(result.success).toBe(true);
    });

    it('should reject spec without requirements', () => {
      const spec = {
        name: 'user-auth',
        overview: 'This spec defines user authentication requirements',
        requirements: [],
      };

      const result = SpecSchema.safeParse(spec);
      expect(result.success).toBe(false);
      if (!result.success) {
        expect(result.error.issues[0].message).toBe('Spec must have at least one requirement');
      }
    });
  });

  describe('ChangeSchema', () => {
    it('should validate a valid change', () => {
      const change = {
        name: 'add-user-auth',
        why: 'We need user authentication to secure the application and protect user data',
        whatChanges: 'Add authentication module with login and logout capabilities',
        deltas: [
          {
            spec: 'user-auth',
            operation: 'ADDED',
            description: 'Add new user authentication spec',
          },
        ],
      };

      const result = ChangeSchema.safeParse(change);
      expect(result.success).toBe(true);
    });

    it('should reject change with short why section', () => {
      const change = {
        name: 'add-user-auth',
        why: 'Need auth',
        whatChanges: 'Add authentication',
        deltas: [
          {
            spec: 'user-auth',
            operation: 'ADDED',
            description: 'Add auth',
          },
        ],
      };

      const result = ChangeSchema.safeParse(change);
      expect(result.success).toBe(false);
      if (!result.success) {
        expect(result.error.issues[0].message).toBe('Why section must be at least 50 characters');
      }
    });

    it('should warn about too many deltas', () => {
      const deltas = Array.from({ length: 11 }, (_, i) => ({
        spec: `spec-${i}`,
        operation: 'ADDED' as const,
        description: `Add spec ${i}`,
      }));

      const change = {
        name: 'massive-change',
        why: 'This is a massive change that affects many parts of the system',
        whatChanges: 'Update everything',
        deltas,
      };

      const result = ChangeSchema.safeParse(change);
      expect(result.success).toBe(false);
      if (!result.success) {
        expect(result.error.issues[0].message).toBe('Consider splitting changes with more than 10 deltas');
      }
    });
  });
});

describe('Validator', () => {
  const testDir = path.join(process.cwd(), 'test-validation-tmp');

  beforeEach(async () => {
    await fs.mkdir(testDir, { recursive: true });
  });

  afterEach(async () => {
    await fs.rm(testDir, { recursive: true, force: true });
  });

  describe('validateSpec', () => {
    it('should validate a valid spec file', async () => {
      const specContent = `# User Authentication Spec

## Purpose
This specification defines the requirements for user authentication in the system.

## Requirements

### The system SHALL provide secure user authentication
The system SHALL provide secure user authentication mechanisms.

#### Scenario: Successful login
Given a user with valid credentials
When they submit the login form
Then they are authenticated and redirected to the dashboard

### The system SHALL handle invalid login attempts
The system SHALL gracefully handle incorrect credentials.

#### Scenario: Invalid credentials
Given a user with invalid credentials
When they submit the login form
Then they see an error message`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const validator = new Validator();
      const report = await validator.validateSpec(specPath);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should detect missing overview section', async () => {
      const specContent = `# User Authentication Spec

## Requirements

### The system SHALL provide secure user authentication

#### Scenario: Login
Given a user
When they login
Then authenticated`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const validator = new Validator();
      const report = await validator.validateSpec(specPath);

      expect(report.valid).toBe(false);
      expect(report.summary.errors).toBeGreaterThan(0);
      expect(report.issues.some(i => i.message.includes('Purpose'))).toBe(true);
    });

    it('should error on delta headers inside a main spec', async () => {
      const specContent = `# Test Specification

## Purpose
This specification validates that stray delta headers are rejected in main specs.

## Requirements

### Requirement: A
The system SHALL do A.

#### Scenario: A works
- **WHEN** foo
- **THEN** bar

## MODIFIED Requirements

### Requirement: B
The system SHALL do B.

#### Scenario: B works
- **WHEN** baz
- **THEN** qux`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const report = await new Validator().validateSpec(specPath);

      expect(report.valid).toBe(false);
      expect(
        report.issues.some(i => i.level === 'ERROR' && i.message.includes('Main spec contains delta header'))
      ).toBe(true);
      expect(
        report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
      ).toBe(true);
    });

    it('should error on requirement headers that appear after the Requirements section ends', async () => {
      const specContent = `# Test Specification

## Purpose
This specification validates that hidden requirements are rejected even without delta headers.

## Requirements

### Requirement: A
The system SHALL do A.

#### Scenario: A works
- **WHEN** foo
- **THEN** bar

## Edge Cases

### Requirement: B
The system SHALL do B.

#### Scenario: B works
- **WHEN** baz
- **THEN** qux`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const report = await new Validator().validateSpec(specPath);

      expect(report.valid).toBe(false);
      expect(
        report.issues.some(i => i.level === 'ERROR' && i.message.includes('Requirement header "### Requirement: B" appears outside'))
      ).toBe(true);
    });

    it('should ignore delta header examples inside fenced code blocks', async () => {
      const specContent = `# Test Specification

## Purpose
This specification documents delta syntax without being flagged for quoted examples.

## Requirements

### Requirement: Explain delta syntax
The system SHALL allow documentation specs to quote delta headers inside fenced code blocks.

\`\`\`markdown
## ADDED Requirements

### Requirement: Example
The system SHALL ...
\`\`\`

#### Scenario: reader follows the example
- **WHEN** a reader reviews the documentation
- **THEN** the quoted delta header remains an example only`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const report = await new Validator().validateSpec(specPath);

      expect(report.valid).toBe(true);
      expect(report.issues.some(i => i.message.includes('Main spec contains delta header'))).toBe(false);
      expect(report.issues.some(i => i.message.includes('appears outside the main ## Requirements section'))).toBe(false);
    });
  });

  describe('validateChange', () => {
    it('should validate a valid change file', async () => {
      const changeContent = `# Add User Authentication

## Why
We need to implement user authentication to secure the application and protect user data from unauthorized access.

## What Changes
- **user-auth:** Add new user authentication specification
- **api-endpoints:** Modify to include auth endpoints`;

      const changePath = path.join(testDir, 'change.md');
      await fs.writeFile(changePath, changeContent);

      const validator = new Validator();
      const report = await validator.validateChange(changePath);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should detect missing why section', async () => {
      const changeContent = `# Add User Authentication

## What Changes
- **user-auth:** Add new user authentication specification`;

      const changePath = path.join(testDir, 'change.md');
      await fs.writeFile(changePath, changeContent);

      const validator = new Validator();
      const report = await validator.validateChange(changePath);

      expect(report.valid).toBe(false);
      expect(report.summary.errors).toBeGreaterThan(0);
      expect(report.issues.some(i => i.message.includes('Why'))).toBe(true);
    });
  });

  describe('strict mode', () => {
    it('should fail on warnings in strict mode', async () => {
      const specContent = `# Test Spec

## Purpose
Brief overview

## Requirements

### The system SHALL do something

#### Scenario: Test
Given test
When action
Then result`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const validator = new Validator(true); // strict mode
      const report = await validator.validateSpec(specPath);

      expect(report.valid).toBe(false); // Should fail due to brief overview warning
    });

    it('should pass warnings in non-strict mode', async () => {
      const specContent = `# Test Spec

## Purpose
Brief overview

## Requirements

### The system SHALL do something

#### Scenario: Test
Given test
When action
Then result`;

      const specPath = path.join(testDir, 'spec.md');
      await fs.writeFile(specPath, specContent);

      const validator = new Validator(false); // non-strict mode
      const report = await validator.validateSpec(specPath);

      expect(report.valid).toBe(true); // Should pass despite warnings
      expect(report.summary.warnings).toBeGreaterThan(0);
    });
  });

  describe('validateChangeDeltaSpecs with metadata', () => {
    it('rejects a delta that both renames and removes the same requirement', async () => {
      // Parity with archive: apply-time rejects this contradiction, so
      // validate must flag it too instead of reporting the change as valid.
      const changeDir = path.join(testDir, 'rename-remove-conflict');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## RENAMED Requirements

- FROM: \`### Requirement: Old name\`
- TO: \`### Requirement: New name\`

## REMOVED Requirements

### Requirement: Old name`;

      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      const msg = report.issues.map((i) => i.message).join('\n');
      expect(msg).toContain('Requirement present in both RENAMED and REMOVED: "Old name"');
    });

    it('rejects a case/whitespace variant of the renamed FROM header in REMOVED', async () => {
      // The contradiction is the same when REMOVED spells the FROM header
      // with different case or spacing - the folded identity must catch it.
      const changeDir = path.join(testDir, 'rename-remove-case-conflict');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## RENAMED Requirements

- FROM: \`### Requirement: Old Name\`
- TO: \`### Requirement: New Name\`

## REMOVED Requirements

### Requirement: old   name`;

      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      const msg = report.issues.map((i) => i.message).join('\n');
      expect(msg).toContain('Requirement present in both RENAMED and REMOVED: "Old Name"');
      expect(msg).toContain('(REMOVED spells it "old   name")');
    });

    it('should validate requirement with metadata before SHALL/MUST text', async () => {
      const changeDir = path.join(testDir, 'test-change');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Circuit Breaker State Management SHALL be implemented
**ID**: REQ-CB-001
**Priority**: P1 (High)

The system MUST implement a circuit breaker with three states.

#### Scenario: Normal operation
**Given** the circuit breaker is in CLOSED state
**When** a request is made
**Then** the request is executed normally`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should validate requirement with SHALL in text but not in header', async () => {
      const changeDir = path.join(testDir, 'test-change-2');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Error Handling
**ID**: REQ-ERR-001
**Priority**: P2

The system SHALL handle all errors gracefully.

#### Scenario: Error occurs
**Given** an error condition
**When** an error occurs
**Then** the error is logged and user is notified`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should fail when a delta spec.md sits directly under specs/', async () => {
      // #1385: the merge path only reads specs/<capability>/spec.md, so a
      // root-level file used to validate clean and then archive with its
      // requirements silently dropped.
      const changeDir = path.join(testDir, 'test-change-root-delta');
      const specsDir = path.join(changeDir, 'specs');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `## ADDED Requirements

### Requirement: Request metrics
The system SHALL record request metrics.

#### Scenario: Request is counted
- **WHEN** a request completes
- **THEN** a counter is incremented`;

      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      expect(
        report.issues.some(i => i.message.includes('Delta spec found at specs/spec.md'))
      ).toBe(true);
      // The precise error replaces the generic one, which would otherwise say
      // "No deltas found" about a file it just named.
      expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false);
    });

    it('should accept a capability folder that is literally named spec.md', async () => {
      const changeDir = path.join(testDir, 'test-change-spec-md-folder');
      const specsDir = path.join(changeDir, 'specs', 'spec.md');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `## ADDED Requirements

### Requirement: Request metrics
The system SHALL record request metrics.

#### Scenario: Request is counted
- **WHEN** a request completes
- **THEN** a counter is incremented`;

      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      // specs/spec.md is a directory here, so nothing is dropped by the merge.
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should still validate a nested capability layout', async () => {
      const changeDir = path.join(testDir, 'test-change-nested-delta');
      const specsDir = path.join(changeDir, 'specs', 'platform', 'metrics');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `## ADDED Requirements

### Requirement: Request metrics
The system SHALL record request metrics.

#### Scenario: Request is counted
- **WHEN** a request completes
- **THEN** a counter is incremented`;

      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('should fail when requirement text lacks SHALL/MUST', async () => {
      const changeDir = path.join(testDir, 'test-change-3');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Logging Feature
**ID**: REQ-LOG-001

The system will log all events.

#### Scenario: Event occurs
**Given** an event
**When** it occurs
**Then** it is logged`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      expect(report.summary.errors).toBeGreaterThan(0);
      expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);
    });

    it('should hint the author when ADDED requirement only has SHALL/MUST in the header', async () => {
      const changeDir = path.join(testDir, 'test-change-shall-in-header-added');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: The system SHALL log all errors
Error handling logic goes here.

#### Scenario: Error occurs
**Given** an error
**When** it occurs
**Then** it is logged`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
      expect(shallMessage?.message).toContain('not only in the header');
      expect(shallMessage?.message).toContain('### Requirement:');
    });

    it('should hint the author when MODIFIED requirement only has SHALL/MUST in the header', async () => {
      const changeDir = path.join(testDir, 'test-change-shall-in-header-modified');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## MODIFIED Requirements

### Requirement: The system MUST validate user input
Please describe how validation should work here.

#### Scenario: Invalid input
**Given** invalid input
**When** validation runs
**Then** an error surfaces`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
      expect(shallMessage?.message).toContain('not only in the header');
      expect(shallMessage?.message).toContain('### Requirement:');
    });

    it('should keep the generic SHALL/MUST error when neither header nor body contain the keyword', async () => {
      const changeDir = path.join(testDir, 'test-change-shall-nowhere');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Logging Feature
The system will log all events.

#### Scenario: Event occurs
**Given** an event
**When** it occurs
**Then** it is logged`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(false);
      const shallMessage = report.issues.find(i => i.message.includes('must contain SHALL or MUST'));
      expect(shallMessage?.message).not.toContain('not only in the header');
    });

    it('should handle requirements without metadata fields', async () => {
      const changeDir = path.join(testDir, 'test-change-4');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Simple Feature
The system SHALL implement this feature.

#### Scenario: Basic usage
**Given** a condition
**When** an action occurs
**Then** a result happens`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });

    it('does not flag requirement headers/scenarios inside fenced code blocks', async () => {
      const changeDir = path.join(testDir, 'test-change-fenced-example');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Documentation Generator
The system SHALL render a delta example in its output.

#### Scenario: Renders an example
**Given** a template
**When** documentation is generated
**Then** the following snippet is produced:

\`\`\`markdown
### Requirement: Example only
#### Scenario: Example scenario
\`\`\`
`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      // The fenced "### Requirement: Example only" must not be parsed as a
      // second (phantom) requirement, which previously produced a spurious
      // "missing requirement text" error.
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
      expect(report.issues.some(i => i.message.includes('Example only'))).toBe(false);
    });

    it('does not count scenario headers inside fenced code blocks toward the required scenario count', async () => {
      const changeDir = path.join(testDir, 'test-change-fenced-scenario-only');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## ADDED Requirements

### Requirement: Documentation Generator
The system SHALL render a delta example in its output.

\`\`\`markdown
#### Scenario: Example scenario
\`\`\`
`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      // The only "#### Scenario:" lives inside a fenced code block, so it must
      // not count toward the scenario requirement; the validator must still
      // flag the requirement as missing a scenario.
      expect(report.valid).toBe(false);
      expect(report.summary.errors).toBeGreaterThan(0);
      expect(
        report.issues.some(i => i.message.includes('must include at least one scenario'))
      ).toBe(true);
    });

    it('should treat delta headers case-insensitively', async () => {
      const changeDir = path.join(testDir, 'test-change-mixed-case');
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });

      const deltaSpec = `# Test Spec

## Added Requirements

### Requirement: Mixed Case Handling
The system MUST support mixed case delta headers.

#### Scenario: Case insensitive parsing
**Given** a delta file with mixed case headers
**When** validation runs
**Then** the delta is detected`;

      const specPath = path.join(specsDir, 'spec.md');
      await fs.writeFile(specPath, deltaSpec);

      const validator = new Validator(true);
      const report = await validator.validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
      expect(report.summary.warnings).toBe(0);
      expect(report.summary.info).toBe(0);
    });

    // #1182b — delta discovery recurses the nested multi-area layout.
    it('discovers and validates deltas in a nested specs/<area>/<capability> layout (#1182b)', async () => {
      const changeDir = path.join(testDir, 'test-change-nested');
      const nestedDir = path.join(changeDir, 'specs', 'area-one', 'cap-a');
      await fs.mkdir(nestedDir, { recursive: true });
      await fs.writeFile(
        path.join(nestedDir, 'spec.md'),
        `## ADDED Requirements\n\n### Requirement: Nested capability\nThe system SHALL support nested multi-area delta layouts.\n\n#### Scenario: Nested delta is discovered\n- **WHEN** validating a change with nested specs\n- **THEN** the delta is found and validated`
      );

      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(report.issues.some(i => i.message.includes('No delta sections found'))).toBe(false);
      expect(report.issues.some(i => i.message.includes('No deltas found'))).toBe(false);
      expect(report.valid).toBe(true);
    });

    it('still validates a single-level layout unchanged (#1182b control)', async () => {
      const changeDir = path.join(testDir, 'test-change-onelevel');
      const oneLevelDir = path.join(changeDir, 'specs', 'cap-a');
      await fs.mkdir(oneLevelDir, { recursive: true });
      await fs.writeFile(
        path.join(oneLevelDir, 'spec.md'),
        `## ADDED Requirements\n\n### Requirement: One level capability\nThe system SHALL support a one-level layout.\n\n#### Scenario: One level delta\n- **WHEN** validating\n- **THEN** the delta is found`
      );

      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });
  });

  // #1156 — the SHALL/MUST body-keyword hint applies to main specs too, with the
  // actionable sentence byte-identical to the change-delta path, emitted once.
  describe('main-spec SHALL/MUST body-keyword hint (#1156)', () => {
    const ACTIONABLE_SENTENCE =
      'must contain SHALL or MUST in the requirement body, not only in the header. Move the SHALL/MUST statement to the line immediately after the "### Requirement: ..." header.';

    const buildSpec = (requirementBlock: string): string =>
      [
        '# Demo Spec',
        '',
        '## Purpose',
        'A purpose long enough to satisfy the validator length threshold for tests.',
        '',
        '## Requirements',
        '',
        requirementBlock,
      ].join('\n');

    const shallIssues = (issues: { message: string }[]) =>
      issues.filter(i => i.message.includes('SHALL or MUST'));

    it('emits the targeted hint when the keyword is in the header only (with a body line)', async () => {
      const content = buildSpec(
        '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'
      );
      const report = await new Validator().validateSpecContent('demo', content);
      const issues = shallIssues(report.issues);
      expect(issues).toHaveLength(1); // exactly one, no duplicate generic
      expect(issues[0].message).toContain('not only in the header');
      expect(issues[0].message).toContain(ACTIONABLE_SENTENCE);
    });

    it('uses an actionable sentence byte-identical to the change-delta message', async () => {
      const block =
        '### Requirement: The system SHALL log\nLogging happens here.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y';

      const specReport = await new Validator().validateSpecContent('demo', buildSpec(block));
      const specMsg = shallIssues(specReport.issues)[0].message;

      const changeDir = path.join(testDir, 'change-parity-sentence');
      const deltaDir = path.join(changeDir, 'specs', 'cap');
      await fs.mkdir(deltaDir, { recursive: true });
      await fs.writeFile(path.join(deltaDir, 'spec.md'), `## ADDED Requirements\n\n${block}`);
      const deltaReport = await new Validator().validateChangeDeltaSpecs(changeDir);
      const deltaMsg = shallIssues(deltaReport.issues)[0].message;

      // Same actionable sentence; only the leading prefix differs.
      expect(specMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true);
      expect(deltaMsg.endsWith(ACTIONABLE_SENTENCE)).toBe(true);
      expect(specMsg.startsWith('Requirement "The system SHALL log"')).toBe(true);
      expect(deltaMsg.startsWith('ADDED "The system SHALL log"')).toBe(true);
    });

    it('keeps a generic missing-keyword error when neither header nor body has the keyword', async () => {
      const content = buildSpec(
        '### Requirement: Logging\nThe system will log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'
      );
      const report = await new Validator().validateSpecContent('demo', content);
      const issues = shallIssues(report.issues);
      expect(issues).toHaveLength(1);
      expect(issues[0].message).not.toContain('not only in the header');
    });

    it('does not flag a requirement whose body line contains the keyword', async () => {
      const content = buildSpec(
        '### Requirement: Logging\nThe system SHALL log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'
      );
      const report = await new Validator().validateSpecContent('demo', content);
      expect(shallIssues(report.issues)).toHaveLength(0);
    });

    it('rejects a lowercase shall/must in the body (matching the delta path)', async () => {
      const content = buildSpec(
        '### Requirement: Logging\nthe system shall log all events.\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'
      );
      const report = await new Validator().validateSpecContent('demo', content);
      expect(shallIssues(report.issues)).toHaveLength(1);
    });

    it('emits the hint for a header-only requirement with no body line (intended additive change)', async () => {
      const content = buildSpec(
        '### Requirement: The system MUST be available\n\n#### Scenario: S\n- **WHEN** x\n- **THEN** y'
      );
      const report = await new Validator().validateSpecContent('demo', content);
      const issues = shallIssues(report.issues);
      expect(issues).toHaveLength(1);
      expect(issues[0].message).toContain('not only in the header');
    });

    it('does not subject RENAMED requirements to the hint (byte-for-byte unchanged)', async () => {
      const changeDir = path.join(testDir, 'change-renamed');
      const deltaDir = path.join(changeDir, 'specs', 'cap');
      await fs.mkdir(deltaDir, { recursive: true });
      await fs.writeFile(
        path.join(deltaDir, 'spec.md'),
        '## RENAMED Requirements\n\n- FROM: `### Requirement: Old name`\n- TO: `### Requirement: The system SHALL do the new thing`\n'
      );
      const report = await new Validator().validateChangeDeltaSpecs(changeDir);
      expect(report.issues.some(i => i.message.includes('not only in the header'))).toBe(false);
    });
  });

  describe('parser reading fidelity (#361, #418, #312, fenced scenario, #498)', () => {
    async function writeChangeDelta(name: string, deltaSpec: string): Promise<string> {
      const changeDir = path.join(testDir, name);
      const specsDir = path.join(changeDir, 'specs', 'test-spec');
      await fs.mkdir(specsDir, { recursive: true });
      await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec);
      return changeDir;
    }

    async function writeSpec(name: string, specContent: string): Promise<string> {
      const specPath = path.join(testDir, `${name}.md`);
      await fs.writeFile(specPath, specContent);
      return specPath;
    }

    it('#361: a normative keyword on a wrapped body line passes both change and spec', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Wrapped keyword
The system performs the described behavior and it
continues onto a second line where SHALL appears in full.

#### Scenario: Wrapped
**Given** a request
**When** it is handled
**Then** the behavior occurs`;

      const changeDir = await writeChangeDelta('fidelity-361', delta);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(changeReport.valid).toBe(true);
      expect(changeReport.summary.errors).toBe(0);

      const spec = `# Test Spec

## Purpose
This spec exercises a normative keyword wrapped onto a second line.

## Requirements

### Requirement: Wrapped keyword
The system performs the described behavior and it
continues onto a second line where SHALL appears in full.

#### Scenario: Wrapped
**Given** a request
**When** it is handled
**Then** the behavior occurs`;

      const specPath = await writeSpec('fidelity-361-spec', spec);
      const specReport = await new Validator(true).validateSpec(specPath);
      expect(specReport.valid).toBe(true);
      expect(specReport.summary.errors).toBe(0);
    });

    it('#418: metadata before the description passes validate <spec> (matching <change>)', async () => {
      const spec = `# Test Spec

## Purpose
This spec exercises metadata fields preceding the requirement description.

## Requirements

### Requirement: Metadata first
**ID**: REQ-FILE-001
**Priority**: P1 (High)
The system MUST persist the uploaded file.

#### Scenario: Persisted
**Given** an uploaded file
**When** the request completes
**Then** the file is stored`;

      const specPath = await writeSpec('fidelity-418-spec', spec);
      const specReport = await new Validator(true).validateSpec(specPath);
      expect(specReport.valid).toBe(true);
      expect(specReport.summary.errors).toBe(0);
    });

    it('#312: a fenced block before the prose line passes both change and spec', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Fence first
\`\`\`bash
# this is a shell comment, not the requirement text
echo hello
\`\`\`
The system SHALL handle fenced examples before the prose line.

#### Scenario: Handled
**Given** a fenced example
**When** the requirement is read
**Then** the prose line is the requirement text`;

      const changeDir = await writeChangeDelta('fidelity-312', delta);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(changeReport.valid).toBe(true);
      expect(changeReport.summary.errors).toBe(0);
    });

    it('fenced scenario: a #### Scenario inside a fence does not count (change matches spec)', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Fenced scenario only
The system SHALL do something real.

\`\`\`markdown
#### Scenario: not a real scenario
- **WHEN** a reader studies the example
- **THEN** it stays inside the fence
\`\`\``;

      const changeDir = await writeChangeDelta('fidelity-fenced-scenario', delta);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      // The only scenario is fenced, so the requirement has zero real scenarios
      // and must fail — the same verdict validate <spec> already gives.
      expect(changeReport.valid).toBe(false);
      expect(
        changeReport.issues.some(i => i.message.includes('must include at least one scenario'))
      ).toBe(true);
    });

    it('#498: a stray ### divider yields an INFO note and does not change valid (even strict)', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Documentation Requirements

### Requirement: Real requirement
The system SHALL do the real thing.

#### Scenario: Works
**Given** a request
**When** it is handled
**Then** the behavior occurs`;

      const changeDir = await writeChangeDelta('fidelity-498', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      // INFO surfaces the stray header but never fails validation.
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
      const info = report.issues.find(
        i => i.level === 'INFO' && i.message.includes('Documentation Requirements')
      );
      expect(info).toBeDefined();
      expect(report.summary.info).toBeGreaterThan(0);
    });

    it('guard: a single-line requirement is read byte-for-byte as before', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Single line
The system SHALL remain unchanged for single-line bodies.

#### Scenario: Unchanged
**Given** a single-line requirement
**When** it is validated
**Then** nothing changes`;

      const changeDir = await writeChangeDelta('fidelity-single-line', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
      expect(report.summary.info).toBe(0);
    });

    it('predicate agrees across readers: a SHALL substring inside a word is not a keyword', async () => {
      // "MARSHALL" contains the substring SHALL but is not a whole-word normative
      // keyword. Both readers must reject it identically (the shared predicate).
      const body = `### Requirement: Marshalling
The MARSHALL coordinates parade logistics.

#### Scenario: Coordinated
**Given** a parade
**When** it begins
**Then** logistics are coordinated`;

      const changeDir = await writeChangeDelta('fidelity-predicate', `# Test Spec\n\n## ADDED Requirements\n\n${body}`);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(changeReport.valid).toBe(false);

      const spec = `# Test Spec

## Purpose
This spec checks that a SHALL substring inside a word is not treated as a keyword.

## Requirements

${body}`;
      const specPath = await writeSpec('fidelity-predicate-spec', spec);
      const specReport = await new Validator(true).validateSpec(specPath);
      expect(specReport.valid).toBe(false);
    });

    it('guard: a metadata-only body without a keyword still fails validation', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Metadata only
**ID**: REQ-META-001
**Priority**: P1 (High)

#### Scenario: Present
**Given** a metadata-only body
**When** it is validated
**Then** validation fails`;

      const changeDir = await writeChangeDelta('fidelity-metadata-only', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(report.valid).toBe(false);
      // The metadata IS the body when nothing else remains, so the failure is
      // the missing keyword, not missing text.
      expect(
        report.issues.some(i => i.message.includes('must contain SHALL or MUST'))
      ).toBe(true);
    });

    it('a requirement written entirely as **Constraint**: metadata keeps its MUST (change and spec)', async () => {
      const body = `### Requirement: Constraint style
**Constraint**: The system MUST respond within the configured deadline.

#### Scenario: Deadline honored
**Given** a configured deadline
**When** a request is handled
**Then** the response arrives in time`;

      const changeDir = await writeChangeDelta('fidelity-constraint-only', `# Test Spec\n\n## ADDED Requirements\n\n${body}`);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(changeReport.valid).toBe(true);
      expect(changeReport.summary.errors).toBe(0);

      const spec = `# Test Spec

## Purpose
This spec exercises a requirement whose whole body is a metadata-style line.

## Requirements

${body}`;
      const specPath = await writeSpec('fidelity-constraint-only-spec', spec);
      const specReport = await new Validator(true).validateSpec(specPath);
      expect(specReport.valid).toBe(true);
      expect(specReport.summary.errors).toBe(0);
    });

    it('canonical empty bodies keep the body-keyword hint on both paths after #1280', async () => {
      const body = `### Requirement: The tool MUST support header-only requirements

#### Scenario: Header only
**Given** a requirement with no body text
**When** it is validated
**Then** both paths ask for the keyword in the body`;

      const changeDir = await writeChangeDelta('fidelity-empty-body', `# Test Spec\n\n## ADDED Requirements\n\n${body}`);
      const changeReport = await new Validator(true).validateChangeDeltaSpecs(changeDir);
      expect(changeReport.valid).toBe(false);
      expect(
        changeReport.issues.some(i => i.message.includes('not only in the header'))
      ).toBe(true);

      const spec = `# Test Spec

## Purpose
This spec exercises the shared body extraction without using the display fallback for validation.

## Requirements

${body}`;
      const specPath = await writeSpec('fidelity-empty-body-spec', spec);
      const specReport = await new Validator(true).validateSpec(specPath);
      expect(specReport.valid).toBe(false);
      expect(
        specReport.issues.some(i => i.message.includes('not only in the header'))
      ).toBe(true);
    });

    it('a stray ### divider ends the requirement body: a MUST in its notes does not count', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Divider absorbed
The system performs the described behavior without a keyword.

### Background
These notes explain that the system MUST NOT be read as requirement text.

#### Scenario: Bounded
**Given** a stray divider
**When** the requirement is read
**Then** the body stops at the divider`;

      const changeDir = await writeChangeDelta('fidelity-divider-body', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      // The body ends at "### Background", so the MUST in the notes is not
      // seen and the requirement fails the keyword check (as it did on main) —
      // and the skipped divider is surfaced as INFO.
      expect(report.valid).toBe(false);
      expect(
        report.issues.some(i => i.level === 'ERROR' && i.message.includes('must contain SHALL or MUST'))
      ).toBe(true);
      expect(
        report.issues.some(i => i.level === 'INFO' && i.message.includes('"### Background"'))
      ).toBe(true);
    });

    it('a nameless "### Requirement:" header gets a dedicated INFO message', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement:

### Requirement: Real requirement
The system SHALL do the real thing.

#### Scenario: Works
**Given** a request
**When** it is handled
**Then** the behavior occurs`;

      const changeDir = await writeChangeDelta('fidelity-nameless', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      const info = report.issues.find(
        i => i.level === 'INFO' && i.message.includes('missing a requirement name')
      );
      expect(info).toBeDefined();
      expect(info!.message).not.toContain('Requirement: Requirement:');
    });

    it('the skipped-header INFO reflects the reader: a fenced divider is not reported', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Fence with divider example
The system SHALL treat fenced headers as content.

\`\`\`markdown
### Not A Real Divider
\`\`\`

#### Scenario: Fenced
**Given** a fenced example containing a level-3 header
**When** the delta is validated
**Then** no INFO note is emitted for it`;

      const changeDir = await writeChangeDelta('fidelity-fenced-divider', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      expect(report.valid).toBe(true);
      expect(report.summary.info).toBe(0);
    });

    it('any #### header counts as a scenario on the delta path (deliberate spec-path parity)', async () => {
      const delta = `# Test Spec

## ADDED Requirements

### Requirement: Notes as scenario
The system SHALL accept any level-4 child, matching the spec path.

#### Notes
The spec path treats every level-4 child of a requirement as a scenario.`;

      const changeDir = await writeChangeDelta('fidelity-h4-parity', delta);
      const report = await new Validator(true).validateChangeDeltaSpecs(changeDir);

      // The spec path (parseScenarios) counts every level-4 child with content
      // as a scenario, so the delta counter deliberately does the same.
      expect(report.valid).toBe(true);
      expect(report.summary.errors).toBe(0);
    });
  });
});


#include <SDL.h>

#define STB_IMAGE_IMPLEMENTATION
#define STB_TRUETYPE_IMPLEMENTATION
#define CLAY_IMPLEMENTATION
#define CLAY_RENDERER_GLES3_IMPLEMENTATION

#include <clay.h>

#include "../../renderers/GLES3/clay_renderer_gles3.h"
#include "../shared-layouts/clay-video-demo.c"
#include "../../renderers/GLES3/clay_renderer_gles3_loader_stb.c"

typedef struct VideoCtx
{
    int shouldContinue;
    SDL_Window *sdlWindow;
    SDL_GLContext sdlContext;
    int screenWidth, screenHeight;
} VideoCtx;

VideoCtx g_ctx;

static int initVideo(VideoCtx *ctx, const int initialWidth, const int initialHeight)
{
    SDL_Init(SDL_INIT_VIDEO);

#if defined(__EMSCRIPTEN__)
    // OpenGL ES 3 profile
    SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
#else
    // Apple MacOs will use it own legacy desktop GL instead
    // I know, I lied, I said this was an GLES3
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
#endif

    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
    SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);

    g_ctx.sdlWindow = SDL_CreateWindow(
        "SDL2 GLES3",
        SDL_WINDOWPOS_CENTERED,
        SDL_WINDOWPOS_CENTERED,
        initialWidth,
        initialHeight,
        SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_SHOWN
    );
    g_ctx.sdlContext = SDL_GL_CreateContext(g_ctx.sdlWindow);

    SDL_ShowWindow(g_ctx.sdlWindow);
    SDL_Delay(1);
    SDL_GL_GetDrawableSize(g_ctx.sdlWindow, &g_ctx.screenWidth, &g_ctx.screenHeight);
    glViewport(0, 0, g_ctx.screenWidth, g_ctx.screenHeight);

    glEnable(GL_BLEND);
    // Enables blending, which allows transparent textures to be rendered properly.
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    // Sets the blending function.
    // - `GL_SRC_ALPHA`: Uses the alpha value of the source (texture or color).
    // - `GL_ONE_MINUS_SRC_ALPHA`: Makes the destination color blend with the background based on alpha.
    // This is commonly used for standard transparency effects.
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glEnable(GL_DEPTH_TEST);
    // Enables depth testing, ensuring that objects closer to the camera are drawn in front of those farther away.
    // This prevents objects from rendering incorrectly based on draw order.

    return 1;
}

void My_ErrorHandler(Clay_ErrorData errorData)
{
    printf("[ClaY ErroR] %s", errorData.errorText.chars);
}

Stb_FontData g_stbFonts[MAX_FONTS]; // Fonts userData
Gles3_Renderer g_gles3;             // The renderer itself

Uint64 NOW = 0;
Uint64 LAST = 0;
double deltaTime = 0;

// is executed before everything
void init()
{
    size_t clayRequiredMemory = Clay_MinMemorySize();
    g_gles3.clayMemory = (Clay_Arena){
        .capacity = clayRequiredMemory,
        .memory = (char *)malloc(clayRequiredMemory),
    };
    Clay_Context *clayCtx = Clay_Initialize(
        g_gles3.clayMemory,
        (Clay_Dimensions){
            .width = (float)g_ctx.screenWidth,
            .height = (float)g_ctx.screenHeight,
        },
        (Clay_ErrorHandler){
            .errorHandlerFunction = My_ErrorHandler,
        });

    // Note that MeasureText has to be set after the Context is set!
    Clay_SetCurrentContext(clayCtx);
    Clay_SetMeasureTextFunction(Stb_MeasureText, &g_stbFonts);
    Gles3_SetRenderTextFunction(&g_gles3, Stb_RenderText, &g_stbFonts);

    Gles3_Initialize(&g_gles3, 4096);

    int atlasW = 1024;
    int atlasH = 1024;
    if (!Stb_LoadFont(
            &g_gles3.fontTextures[0],
            &g_stbFonts[0],
            "resources/Roboto-Regular.ttf",
            24.0f, // bake pixel height
            atlasW,
            atlasH))
        abort();

    Clay_SetDebugModeEnabled(true);
}

void loop()
{

    glClearColor(0.1f, 0.2f, 0.1f, 1.0f);

    Clay_Vector2 scrollDelta = {};
    SDL_Event event;
    while (SDL_PollEvent(&event))
    {
        switch (event.type)
        {
        case SDL_QUIT:
        {
            g_ctx.shouldContinue = false;
        }
        case SDL_MOUSEWHEEL:
        {
            scrollDelta.x = event.wheel.x;
            scrollDelta.y = event.wheel.y;
            break;
        }
        }
    }
    LAST = NOW;
    NOW = SDL_GetPerformanceCounter();
    deltaTime = (double)((NOW - LAST) * 1000 / (double)SDL_GetPerformanceFrequency());

    int mouseX = 0;
    int mouseY = 0;
    Uint32 mouseState = SDL_GetMouseState(&mouseX, &mouseY);
    Clay_Vector2 mousePosition = (Clay_Vector2){(float)mouseX, (float)mouseY};
    Clay_SetPointerState(mousePosition, mouseState & SDL_BUTTON(1));

    Clay_UpdateScrollContainers(
        true,
        (Clay_Vector2){scrollDelta.x, scrollDelta.y},
        deltaTime);

    SDL_GL_GetDrawableSize(g_ctx.sdlWindow, &g_ctx.screenWidth, &g_ctx.screenHeight);
    glViewport(0, 0, g_ctx.screenWidth, g_ctx.screenHeight);
    Clay_SetLayoutDimensions((Clay_Dimensions){(float)g_ctx.screenWidth, (float)g_ctx.screenHeight});

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glDisable(GL_DEPTH_TEST);
    glDepthMask(GL_FALSE); // Clay renderer is simple and never writes to depth buffer

    ClayVideoDemo_Data data = ClayVideoDemo_Initialize();
    Clay_RenderCommandArray cmds = ClayVideoDemo_CreateLayout(&data);

    Gles3_Render(&g_gles3, cmds, g_stbFonts);

    SDL_GL_SwapWindow(g_ctx.sdlWindow);
}

// Just initializes and spins the animation loop
int main()
{
    initVideo(&g_ctx, 1280, 720);
    init();

    g_ctx.shouldContinue = true;
#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop(loop, 0, 1);
#else
    while (g_ctx.shouldContinue)
    {
        loop();
    }
#endif
}


/*
 * Copyright 2024-2025 the original author or authors.
 */

package io.modelcontextprotocol.spec;

import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;

import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;

/**
 * An implementation of {@link McpTransportStream} using Project Reactor types.
 *
 * @param <CONNECTION> the resource serving the stream
 * @author Dariusz Jędrzejczyk
 */
public class DefaultMcpTransportStream<CONNECTION> implements McpTransportStream<CONNECTION> {

	private static final Logger logger = LoggerFactory.getLogger(DefaultMcpTransportStream.class);

	private static final AtomicLong counter = new AtomicLong();

	private final AtomicReference<String> lastId = new AtomicReference<>();

	// Used only for internal accounting
	private final long streamId;

	private final boolean resumable;

	private final Function<McpTransportStream<CONNECTION>, Publisher<CONNECTION>> reconnect;

	/**
	 * Constructs a new instance representing a particular stream that can resume using
	 * the provided reconnect mechanism.
	 * @param resumable whether the stream is resumable and should try to reconnect
	 * @param reconnect the mechanism to use in case an error is observed on the current
	 * event stream to asynchronously kick off a resumed stream consumption, potentially
	 * using the stored {@link #lastId()}.
	 */
	public DefaultMcpTransportStream(boolean resumable,
			Function<McpTransportStream<CONNECTION>, Publisher<CONNECTION>> reconnect) {
		this.reconnect = reconnect;
		this.streamId = counter.getAndIncrement();
		this.resumable = resumable;
	}

	@Override
	public Optional<String> lastId() {
		return Optional.ofNullable(this.lastId.get());
	}

	@Override
	public long streamId() {
		return this.streamId;
	}

	@Override
	public Publisher<McpSchema.JSONRPCMessage> consumeSseStream(
			Publisher<Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>>> eventStream) {

		// @formatter:off
		return Flux.deferContextual(ctx -> Flux.from(eventStream)
			.doOnNext(idAndMessage -> idAndMessage.getT1().ifPresent(id -> {
				String previousId = this.lastId.getAndSet(id);
				logger.debug("Updating last id {} -> {} for stream {}", previousId, id, this.streamId);
			}))
			.doOnError(e -> {
				if (resumable && !(e instanceof McpTransportSessionNotFoundException)) {
					Mono.from(reconnect.apply(this)).contextWrite(ctx).subscribe();
				}
			})
			.flatMapIterable(Tuple2::getT2)); // @formatter:on
	}

}