import { MalformedBlockError, MissingArtifactError, OversizeBlockError } from '../errors.js';
const KNOWN_ROLES = new Set(['designer', 'critic', 'brand', 'a11y', 'copy']);
// Hoisted regexes reused across emitInner invocations. Reset lastIndex before each loop.
const DIM_RE = /<DIM\s+name="([^"]+)"\s+score="([^"]+)">([\s\S]*?)<\/DIM>/g;
const MUST_FIX_RE = /<MUST_FIX>([\s\S]*?)<\/MUST_FIX>/g;
const DEFAULT_SCORE_SCALE = 10;
export async function* parseV1(source, opts) {
    const state = {
        buf: '',
        consumed: 0,
        runId: opts.runId,
        adapter: opts.adapter,
        protocolVersion: 1,
        scoreScale: DEFAULT_SCORE_SCALE,
        parserMaxBlockBytes: opts.parserMaxBlockBytes,
        projectId: opts.projectId ?? '',
        artifactId: opts.artifactId ?? '',
        inRun: false,
        currentRound: null,
        roundsClosed: 0,
        shipSeen: false,
        designerArtifactInRound1: false,
        lastAdvance: 0,
        onArtifact: opts.onArtifact,
    };
    for await (const chunk of source) {
        state.buf += chunk;
        yield* drain(state);
        // After drain, anything still in the buffer is a partial tag waiting on more input.
        // If that pending block is bigger than the cap, the producer is stuck inside one
        // unclosed block and we have to fail rather than buffer indefinitely. Compare in
        // UTF-8 bytes (mrcfps review #2) so a buffer full of CJK or emoji cannot exceed
        // the configured byte cap while staying under the JS string length cap.
        const bufBytes = Buffer.byteLength(state.buf, 'utf8');
        if (bufBytes > opts.parserMaxBlockBytes) {
            throw new OversizeBlockError(`block exceeded ${opts.parserMaxBlockBytes} bytes at position ${state.consumed}`, state.consumed);
        }
    }
    yield* drain(state);
    // End-of-stream invariants.
    if (state.inRun && !state.shipSeen) {
        throw new MalformedBlockError(`CRITIQUE_RUN never closed (no </CRITIQUE_RUN> and no <SHIP>) at position ${state.consumed}`, state.consumed);
    }
}
function* drain(state) {
    let cursor = 0;
    while (cursor < state.buf.length) {
        const slice = state.buf.slice(cursor);
        // <CRITIQUE_RUN ...>
        if (slice.startsWith('<CRITIQUE_RUN ')) {
            const close = slice.indexOf('>');
            if (close < 0)
                break;
            const attrs = parseAttrs(slice.slice('<CRITIQUE_RUN'.length, close));
            state.protocolVersion = Number(attrs['version'] ?? '1');
            const declaredScale = Number(attrs['scale'] ?? String(DEFAULT_SCORE_SCALE));
            state.scoreScale = isFinite(declaredScale) && declaredScale > 0 ? declaredScale : DEFAULT_SCORE_SCALE;
            state.inRun = true;
            yield {
                type: 'run_started',
                runId: state.runId,
                protocolVersion: state.protocolVersion,
                cast: ['designer', 'critic', 'brand', 'a11y', 'copy'],
                maxRounds: Number(attrs['maxRounds'] ?? '3'),
                threshold: Number(attrs['threshold'] ?? '8.0'),
                scale: state.scoreScale,
            };
            cursor += close + 1;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // <ROUND n="N">
        const roundMatch = slice.match(/^<ROUND\s+([^>]*)>/);
        if (roundMatch) {
            // Envelope guard (mrcfps review #2): no run-level event may appear before
            // <CRITIQUE_RUN ...> opens the envelope, otherwise downstream consumers
            // see contract-shaped events without the required run_started handshake.
            if (!state.inRun) {
                throw new MalformedBlockError(`<ROUND> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`, state.consumed + cursor);
            }
            const a = parseAttrs(roundMatch[1] ?? '');
            state.currentRound = Number(a['n']);
            cursor += roundMatch[0].length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // <PANELIST ...>...</PANELIST>
        if (slice.startsWith('<PANELIST ') ||
            slice.startsWith('<PANELIST\t') ||
            slice.startsWith('<PANELIST\n')) {
            if (!state.inRun) {
                throw new MalformedBlockError(`<PANELIST> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`, state.consumed + cursor);
            }
            const closeIdx = slice.indexOf('</PANELIST>');
            if (closeIdx < 0)
                break;
            // Per-block size enforcement (mrcfps review): a complete oversized block
            // that arrives in one large chunk would otherwise slip past the post-drain
            // buf-size check because its body would be sliced and emitted before the
            // check ran. Catch it here, before any work happens. Use UTF-8 byte length
            // so multibyte content (CJK, emoji) cannot bypass the byte-defined cap.
            const blockText = slice.slice(0, closeIdx + '</PANELIST>'.length);
            const blockBytes = Buffer.byteLength(blockText, 'utf8');
            if (blockBytes > state.parserMaxBlockBytes) {
                throw new OversizeBlockError(`PANELIST block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`, state.consumed + cursor);
            }
            const headEnd = slice.indexOf('>');
            // headEnd must be the opener's closing >, which has to come BEFORE the
            // matched </PANELIST>. Without this guard a malformed opener like
            // <PANELIST role="critic" score="8"</PANELIST> (no opening >) would
            // pick up the closing tag's > and emit panelist events for an invalid block.
            if (headEnd < 0)
                break;
            if (headEnd >= closeIdx) {
                throw new MalformedBlockError(`<PANELIST> opening tag at position ${state.consumed + cursor} has no closing > before </PANELIST>`, state.consumed + cursor);
            }
            const head = slice.slice('<PANELIST'.length, headEnd);
            const body = slice.slice(headEnd + 1, closeIdx);
            // Nesting guard: if another <PANELIST opening appears inside what we believe
            // is this PANELIST body, the current block was never closed and we are about
            // to mis-attribute the next sibling's content. Treat as malformed.
            if (/<PANELIST[\s>]/.test(body)) {
                throw new MalformedBlockError(`PANELIST block at position ${state.consumed + cursor} never closed before the next <PANELIST opening`, state.consumed + cursor);
            }
            const attrs = parseAttrs(head);
            const roleStr = attrs['role'];
            if (!roleStr || !KNOWN_ROLES.has(roleStr)) {
                yield {
                    type: 'parser_warning',
                    runId: state.runId,
                    kind: 'unknown_role',
                    position: state.consumed + cursor,
                };
                cursor += closeIdx + '</PANELIST>'.length;
                state.lastAdvance = state.consumed + cursor;
                continue;
            }
            const role = roleStr;
            // A PANELIST block must appear inside a <ROUND n="..."> envelope. If no round
            // has been opened (or the n attribute parsed to NaN), the stream is malformed
            // and emitting events with an invalid round would corrupt every downstream
            // consumer (reducer, scoreboard, persistence).
            if (state.currentRound == null || !Number.isFinite(state.currentRound)) {
                throw new MalformedBlockError(`PANELIST at position ${state.consumed + cursor} appeared before a valid <ROUND n="..."> opening`, state.consumed + cursor);
            }
            const round = state.currentRound;
            yield { type: 'panelist_open', runId: state.runId, round, role };
            yield* emitInner(state, role, body);
            const rawScore = Number(attrs['score'] ?? '0');
            const score = clampScore(rawScore, state.scoreScale);
            if (isOutOfRange(rawScore, state.scoreScale)) {
                yield {
                    type: 'parser_warning',
                    runId: state.runId,
                    kind: 'score_clamped',
                    position: state.consumed + cursor,
                };
            }
            yield { type: 'panelist_close', runId: state.runId, round, role, score };
            cursor += closeIdx + '</PANELIST>'.length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // <ROUND_END n="N" ...>...</ROUND_END>
        if (slice.startsWith('<ROUND_END ')) {
            if (!state.inRun) {
                throw new MalformedBlockError(`<ROUND_END> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`, state.consumed + cursor);
            }
            const closeIdx = slice.indexOf('</ROUND_END>');
            if (closeIdx < 0)
                break;
            const blockText = slice.slice(0, closeIdx + '</ROUND_END>'.length);
            const blockBytes = Buffer.byteLength(blockText, 'utf8');
            if (blockBytes > state.parserMaxBlockBytes) {
                throw new OversizeBlockError(`ROUND_END block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`, state.consumed + cursor);
            }
            const headEnd = slice.indexOf('>');
            if (headEnd < 0)
                break;
            if (headEnd >= closeIdx) {
                throw new MalformedBlockError(`<ROUND_END> opening tag at position ${state.consumed + cursor} has no closing > before </ROUND_END>`, state.consumed + cursor);
            }
            const attrs = parseAttrs(slice.slice('<ROUND_END'.length, headEnd));
            const inner = slice.slice(headEnd + 1, closeIdx);
            const reason = (inner.match(/<REASON>([\s\S]*?)<\/REASON>/)?.[1] ?? '').trim();
            // The wire protocol (spec § Wire protocol parser invariants) requires the
            // designer to emit exactly one <ARTIFACT> in round 1. Subsequent rounds may
            // omit ARTIFACT and ship NOTES-only (the designer is iterating in place).
            // If protocol v2 ever relaxes this to "at any point before SHIP", widen the
            // check to use a `designerArtifactSeen` flag instead.
            if (state.currentRound === 1 && !state.designerArtifactInRound1) {
                throw new MissingArtifactError(`round 1 closed at position ${state.consumed + cursor} without designer ARTIFACT`);
            }
            yield {
                type: 'round_end',
                runId: state.runId,
                round: Number(attrs['n']),
                composite: Number(attrs['composite'] ?? '0'),
                mustFix: Number(attrs['must_fix'] ?? '0'),
                decision: attrs['decision'] === 'ship' ? 'ship' : 'continue',
                reason,
            };
            state.currentRound = null;
            state.roundsClosed += 1;
            cursor += closeIdx + '</ROUND_END>'.length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // </ROUND>
        if (slice.startsWith('</ROUND>')) {
            cursor += '</ROUND>'.length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // <SHIP ...>...</SHIP>
        if (slice.startsWith('<SHIP ')) {
            if (!state.inRun) {
                throw new MalformedBlockError(`<SHIP> at position ${state.consumed + cursor} appeared before <CRITIQUE_RUN>`, state.consumed + cursor);
            }
            // Envelope guard: SHIP must not arrive before at least one round has
            // completed. A stream that skips directly from <CRITIQUE_RUN> to <SHIP>
            // bypasses the round-1 designer-artifact invariant.
            if (state.roundsClosed === 0) {
                throw new MalformedBlockError(`<SHIP> at position ${state.consumed + cursor} appeared before any <ROUND_END>`, state.consumed + cursor);
            }
            // Look up `</SHIP>` while skipping over any `<![CDATA[ ... ]]>` spans
            // inside the body. A naive `indexOf` would match a literal `</SHIP>`
            // string sitting inside a JS / HTML payload wrapped in CDATA and
            // truncate the SHIP block early, dropping the real `</ARTIFACT>` and
            // `</SUMMARY>` siblings (lefarcen P2 on PR #1085).
            const closeIdx = indexOfOutsideCdata(slice, '</SHIP>');
            if (closeIdx < 0)
                break;
            const blockText = slice.slice(0, closeIdx + '</SHIP>'.length);
            const blockBytes = Buffer.byteLength(blockText, 'utf8');
            if (blockBytes > state.parserMaxBlockBytes) {
                throw new OversizeBlockError(`SHIP block of ${blockBytes} bytes exceeded ${state.parserMaxBlockBytes} at position ${state.consumed + cursor}`, state.consumed + cursor);
            }
            if (state.shipSeen) {
                yield {
                    type: 'parser_warning',
                    runId: state.runId,
                    kind: 'duplicate_ship',
                    position: state.consumed + cursor,
                };
                cursor += closeIdx + '</SHIP>'.length;
                state.lastAdvance = state.consumed + cursor;
                continue;
            }
            state.shipSeen = true;
            const headEnd = slice.indexOf('>');
            if (headEnd < 0)
                break;
            if (headEnd >= closeIdx) {
                throw new MalformedBlockError(`<SHIP> opening tag at position ${state.consumed + cursor} has no closing > before </SHIP>`, state.consumed + cursor);
            }
            const attrs = parseAttrs(slice.slice('<SHIP'.length, headEnd));
            const inner = slice.slice(headEnd + 1, closeIdx);
            // Validate that a non-empty <ARTIFACT> block is present inside <SHIP>
            // and capture its head + body so the orchestrator can persist the bytes
            // to disk via the side-channel callback. The body must NOT be added to
            // the ship PanelEvent itself, since that event is also the SSE wire
            // shape; broadcasting megabytes of HTML to every SSE subscriber would
            // ruin the bus. The orchestrator pulls the body off `onArtifact` and
            // emits only the small `artifactRef` on the wire.
            const artifactExtraction = extractArtifactBlock(inner);
            if (artifactExtraction === null
                || artifactExtraction.body.trim().length === 0) {
                throw new MissingArtifactError(`<SHIP> at position ${state.consumed + cursor} contains no <ARTIFACT> block or the block is empty`);
            }
            const artifactAttrs = parseAttrs(artifactExtraction.attrText);
            const artifactMime = artifactAttrs['mime'] ?? '';
            // CDATA-wrapped bodies are unwrapped inside `extractArtifactBlock`,
            // which scans for `]]></ARTIFACT>` (with optional whitespace) so a
            // legitimate JS / HTML payload that contains a literal `</ARTIFACT>`
            // sentinel inside a string or comment does not truncate the body
            // (mrcfps follow-up on PR #1085).
            const artifactBody = artifactExtraction.body;
            // Scope the SUMMARY scan to bytes that come AFTER the artifact's
            // closing tag. Searching the full SHIP `inner` would let an artifact
            // body that contains a literal `<SUMMARY>...</SUMMARY>` pair (for
            // example a CDATA-wrapped HTML fragment) hijack the ship summary
            // before the real sibling tag is reached, so rerun / history would
            // display artifact bytes as the summary text (mrcfps follow-up on
            // PR #1085).
            const summary = (inner
                .slice(artifactExtraction.blockEnd)
                .match(/<SUMMARY>([\s\S]*?)<\/SUMMARY>/)?.[1] ?? '').trim();
            const rawStatus = attrs['status'] ?? '';
            const validStatuses = ['shipped', 'below_threshold', 'timed_out', 'interrupted'];
            const status = (validStatuses.includes(rawStatus)
                ? rawStatus
                : 'shipped');
            const shipRound = Number(attrs['round'] ?? '0');
            // Side-channel hand-off BEFORE the ship event yields, so the
            // orchestrator can write artifact.<ext> and pin artifactPath on the
            // run row before any consumer of the ship event reacts to it.
            if (state.onArtifact) {
                state.onArtifact({
                    round: shipRound,
                    mime: artifactMime,
                    body: artifactBody,
                });
            }
            yield {
                type: 'ship',
                runId: state.runId,
                round: shipRound,
                composite: Number(attrs['composite'] ?? '0'),
                status,
                artifactRef: { projectId: state.projectId, artifactId: state.artifactId },
                summary,
            };
            cursor += closeIdx + '</SHIP>'.length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // </CRITIQUE_RUN>
        if (slice.startsWith('</CRITIQUE_RUN>')) {
            state.inRun = false;
            cursor += '</CRITIQUE_RUN>'.length;
            state.lastAdvance = state.consumed + cursor;
            continue;
        }
        // Whitespace: skip
        const ch = slice.charAt(0);
        if (ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t') {
            cursor += 1;
            continue;
        }
        // Unknown '<': wait for more bytes (partial tag across chunk boundary)
        if (ch === '<') {
            break;
        }
        // Non-whitespace, non-tag character inside CRITIQUE_RUN: malformed
        if (state.inRun) {
            throw new MalformedBlockError(`unexpected character "${ch}" at position ${state.consumed + cursor}`, state.consumed + cursor);
        }
        cursor += 1;
    }
    state.consumed += cursor;
    state.buf = state.buf.slice(cursor);
}
function* emitInner(state, role, inner) {
    // emitInner is on the parser hot path. Reuse the module-level regex objects
    // and reset lastIndex so successive runs don't see stale match state.
    const round = state.currentRound;
    if (round == null || !Number.isFinite(round)) {
        // Defensive: callers should already have rejected this, but emitting a
        // panelist_dim with an invalid round value would corrupt downstream state.
        return;
    }
    DIM_RE.lastIndex = 0;
    let dm;
    while ((dm = DIM_RE.exec(inner)) !== null) {
        const raw = Number(dm[2]);
        const dimScore = clampScore(raw, state.scoreScale);
        if (isOutOfRange(raw, state.scoreScale)) {
            yield {
                type: 'parser_warning',
                runId: state.runId,
                kind: 'score_clamped',
                position: state.consumed,
            };
        }
        yield {
            type: 'panelist_dim',
            runId: state.runId,
            round,
            role,
            dimName: dm[1] ?? '',
            dimScore,
            dimNote: (dm[3] ?? '').trim(),
        };
    }
    MUST_FIX_RE.lastIndex = 0;
    let mf;
    while ((mf = MUST_FIX_RE.exec(inner)) !== null) {
        yield {
            type: 'panelist_must_fix',
            runId: state.runId,
            round,
            role,
            text: (mf[1] ?? '').trim(),
        };
    }
    // The round-1 designer artifact invariant is checked at ROUND_END close. We
    // only flip the flag here so that ROUND_END knows the artifact arrived.
    if (role === 'designer' && round === 1 && /<ARTIFACT\b/.test(inner)) {
        state.designerArtifactInRound1 = true;
    }
}
function parseAttrs(s) {
    const out = {};
    const re = /([a-zA-Z_]+)\s*=\s*"([^"]*)"/g;
    let m;
    while ((m = re.exec(s)) !== null) {
        const key = m[1];
        if (key != null)
            out[key] = m[2] ?? '';
    }
    return out;
}
/**
 * Find the first occurrence of `needle` in `source` that is NOT inside a
 * `<![CDATA[ ... ]]>` span. The wire protocol uses sibling tags (`</SHIP>`,
 * `</ARTIFACT>`, `<SUMMARY>`) that an agent could legitimately ship inside
 * an HTML / JS payload wrapped in CDATA — a naive `indexOf` would match
 * those sentinels, slicing the wrong block out and corrupting both the
 * extraction and the surrounding parser state. CDATA forbids the literal
 * `]]>` inside its content per the XML spec, so once we open one we can
 * always find its terminator and resume scanning afterward.
 *
 * Returns -1 when no out-of-CDATA match exists, including the case where
 * an unterminated CDATA span runs to the end of the source. Streaming
 * callers should treat that as "not enough bytes yet" and break.
 */
export function indexOfOutsideCdata(source, needle, startOffset = 0) {
    let i = startOffset;
    while (i < source.length) {
        if (source.startsWith('<![CDATA[', i)) {
            const cdataEnd = source.indexOf(']]>', i + '<![CDATA['.length);
            if (cdataEnd < 0) {
                // Unterminated CDATA: the rest of the buffer is opaque. The
                // streaming SHIP path treats this the same as `indexOf` returning
                // -1 and waits for more bytes; the post-SHIP-close artifact path
                // treats it as malformed (since the SHIP closer was already found
                // outside CDATA, every CDATA inside `inner` should be terminated).
                return -1;
            }
            i = cdataEnd + ']]>'.length;
            continue;
        }
        if (source.startsWith(needle, i))
            return i;
        i += 1;
    }
    return -1;
}
/**
 * Locate the `<ARTIFACT ...>` block inside the SHIP body and return its
 * attribute string + decoded payload. CDATA-aware: when the artifact body
 * starts with `<![CDATA[`, the function scans for the matching
 * `]]></ARTIFACT>` pair (allowing whitespace between `]]>` and `</ARTIFACT>`)
 * rather than the first bare `</ARTIFACT>`, so a payload containing a
 * literal `</ARTIFACT>` inside a JS string or comment is delivered intact.
 *
 * `blockEnd` is the offset in `source` immediately after the closing
 * `</ARTIFACT>` tag, so callers that need to scan SHIP-level sibling tags
 * (`<SUMMARY>`, etc.) can do so on `source.slice(blockEnd)` instead of the
 * full SHIP inner — otherwise an artifact body that contains a literal
 * `<SUMMARY>...</SUMMARY>` pair would be misread as the ship summary
 * (mrcfps follow-up on PR #1085).
 *
 * Returns `null` if no `<ARTIFACT>` opener exists, no closing tag matches,
 * or the body decodes to an empty string.
 */
export function extractArtifactBlock(source) {
    const openerMatch = source.match(/<ARTIFACT\b([^>]*)>/);
    if (!openerMatch
        || openerMatch.index === undefined
        || openerMatch[1] === undefined) {
        return null;
    }
    const attrText = openerMatch[1];
    const bodyStart = openerMatch.index + openerMatch[0].length;
    const remainder = source.slice(bodyStart);
    // CDATA-wrapped path: scan for `]]>` followed by optional whitespace then
    // `</ARTIFACT>`. The `]]>` MUST come from the CDATA terminator, never from
    // body text, so this is safe even when the body itself contains the bytes
    // `</ARTIFACT>` inside a string or comment. CDATA forbids the literal
    // sequence `]]>` inside its content per the XML spec, so the first match
    // is always the real terminator.
    const trimmed = remainder.trimStart();
    const leadingWs = remainder.length - trimmed.length;
    if (trimmed.startsWith('<![CDATA[')) {
        // `cdataInnerStart` is an offset into `remainder` (the slice of
        // `source` that begins right after the `<ARTIFACT ...>` opener), so
        // every subsequent slice must be against `remainder` too — slicing
        // `source` here would shift by `bodyStart` and chop off the leading
        // bytes of the body.
        const cdataInnerStart = leadingWs + '<![CDATA['.length;
        // Allow whitespace between `]]>` and `</ARTIFACT>` so authors can
        // pretty-print. CDATA forbids the literal `]]>` inside its content
        // per the XML spec, so the first match is always the real terminator.
        const tailRe = /\]\]>\s*<\/ARTIFACT>/;
        const tailMatch = remainder.slice(cdataInnerStart).match(tailRe);
        if (!tailMatch || tailMatch.index === undefined || tailMatch[0] === undefined) {
            // Open CDATA without a CDATA-terminated closer: malformed.
            return null;
        }
        const body = remainder.slice(cdataInnerStart, cdataInnerStart + tailMatch.index);
        const blockEnd = bodyStart + cdataInnerStart + tailMatch.index + tailMatch[0].length;
        return { attrText, body, blockEnd };
    }
    // Inline (non-CDATA) path: stop at the first `</ARTIFACT>` that is not
    // itself nested inside a stray CDATA span. Bodies that need to embed
    // `</ARTIFACT>` literally must wrap themselves in CDATA; that contract
    // matches the v1 spec's recommended emitter, but if the body happens to
    // open a CDATA span before its real `</ARTIFACT>` terminator we still
    // skip past it for safety.
    const closeIdx = indexOfOutsideCdata(remainder, '</ARTIFACT>');
    if (closeIdx < 0)
        return null;
    const body = remainder.slice(0, closeIdx);
    const blockEnd = bodyStart + closeIdx + '</ARTIFACT>'.length;
    return { attrText, body, blockEnd };
}
// Score range and clamp now respect the run's declared scale (captured from
// <CRITIQUE_RUN scale="..."> into State.scoreScale). Without this a value of
// 42 in a scale=10 run would sneak through and warp composite math.
function isOutOfRange(n, scale) {
    if (!isFinite(n))
        return true;
    return n < 0 || n > scale;
}
function clampScore(n, scale) {
    if (!isFinite(n))
        return 0;
    if (n < 0)
        return 0;
    if (n > scale)
        return scale;
    return n;
}
//# sourceMappingURL=v1.js.map