<?php

declare(strict_types=1);

/*
 * Fixed PHP bridge executed from the runtime's private virtual filesystem.
 *
 * The browser writes user-controlled values to request.json as JSON. This
 * script never evaluates PHP source assembled from those values. The only
 * generated string is a preg pattern made from the raw expression, a safe
 * delimiter and validated preg modifiers.
 */

const REGEX_TOOLS_ABI = 1;
const REGEX_TOOLS_REQUEST = '/regex-tools/request.json';
const REGEX_TOOLS_MAX_PATTERN_UTF16 = 65_536;
const REGEX_TOOLS_MAX_SUBJECT_BYTES = 16_777_216;
const REGEX_TOOLS_MAX_REPLACEMENT_UTF16 = 65_536;
const REGEX_TOOLS_MAX_OUTPUT_BYTES = 67_108_864;
const REGEX_TOOLS_MAX_MATCHES = 10_000;
const REGEX_TOOLS_MAX_CAPTURE_ROWS = 100_000;
const REGEX_TOOLS_MAX_CAPTURE_GROUPS = 1_000;
const REGEX_TOOLS_MAX_ERROR_CHARACTERS = 1_024;
const REGEX_TOOLS_FLAGS = 'gimsxuUADJnr';

final class RegexToolsFailure extends RuntimeException
{
    public function __construct(
        public readonly string $phase,
        public readonly string $failureCode,
        string $message,
        public readonly ?int $patternOffset = null,
    ) {
        parent::__construct($message);
    }
}

/**
 * @return array<string, mixed>
 */
function regex_tools_failure(Throwable $error): array
{
    $phase = $error instanceof RegexToolsFailure ? $error->phase : 'bridge';
    $code = $error instanceof RegexToolsFailure
        ? $error->failureCode
        : 'bridge-error';
    $message = $error->getMessage();
    if (preg_match('//u', $message) !== 1) {
        $message = 'The PHP runtime reported a non-UTF-8 error.';
    }
    if (strlen($message) > REGEX_TOOLS_MAX_ERROR_CHARACTERS) {
        $message = substr($message, 0, REGEX_TOOLS_MAX_ERROR_CHARACTERS - 3);
        while ($message !== '' && preg_match('//u', $message) !== 1) {
            $message = substr($message, 0, -1);
        }
        $message .= '…';
    }
    $result = [
        'abi' => REGEX_TOOLS_ABI,
        'ok' => false,
        'phase' => $phase,
        'code' => $code,
        'message' => $message,
    ];
    if ($error instanceof RegexToolsFailure && $error->patternOffset !== null) {
        $result['position'] = $error->patternOffset;
    }
    return $result;
}

function regex_tools_preg_warning(
    int $severity,
    string $message,
    string $file,
    int $line,
): never {
    unset($file, $line);
    $offset = null;
    if (preg_match('/\\bat offset (\\d+)\\z/D', $message, $offsetMatch) === 1) {
        $offset = (int) $offsetMatch[1];
    }
    throw new RegexToolsFailure(
        'compile',
        'compile-error',
        preg_replace('/^preg_[a-z_]+\\(\\):\\s*/', '', $message) ?? $message,
        $offset,
    );
}

set_error_handler(
    static function (
        int $severity,
        string $message,
        string $file,
        int $line,
    ): never {
        regex_tools_preg_warning($severity, $message, $file, $line);
    },
);
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('html_errors', '0');

/**
 * @param mixed $condition
 */
function regex_tools_require(
    mixed $condition,
    string $message,
    string $code = 'invalid-request',
): void {
    if (!$condition) {
        throw new RegexToolsFailure('bridge', $code, $message);
    }
}

function regex_tools_within_utf16_limit(
    string $value,
    int $maximumUnits,
): bool {
    $units = 0;
    $length = strlen($value);
    for ($index = 0; $index < $length;) {
        $leadingByte = ord($value[$index]);
        if ($leadingByte < 0x80) {
            $index++;
            $units++;
        } elseif ($leadingByte < 0xE0) {
            $index += 2;
            $units++;
        } elseif ($leadingByte < 0xF0) {
            $index += 3;
            $units++;
        } else {
            $index += 4;
            $units += 2;
        }
        if ($units > $maximumUnits) {
            return false;
        }
    }
    return true;
}

/**
 * @return array<string, mixed>
 */
function regex_tools_request(): array
{
    $encoded = file_get_contents(REGEX_TOOLS_REQUEST);
    regex_tools_require(
        is_string($encoded),
        'The PHP bridge request file could not be read.',
    );
    try {
        $request = json_decode(
            $encoded,
            true,
            64,
            JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING,
        );
    } catch (JsonException $error) {
        throw new RegexToolsFailure(
            'bridge',
            'invalid-json',
            'The PHP bridge request is not valid JSON: ' . $error->getMessage(),
        );
    }
    regex_tools_require(
        is_array($request) && !array_is_list($request),
        'The PHP bridge request must be a JSON object.',
    );
    regex_tools_require(
        ($request['abi'] ?? null) === REGEX_TOOLS_ABI,
        'The PHP bridge ABI does not match.',
        'abi-mismatch',
    );
    return $request;
}

/**
 * @param array<string, mixed> $request
 */
function regex_tools_validate_request(array $request): void
{
    $operation = $request['operation'] ?? null;
    regex_tools_require(
        $operation === 'execute' || $operation === 'replace',
        'The PHP bridge operation is unsupported.',
        'invalid-operation',
    );
    foreach (['pattern', 'flags', 'subject'] as $field) {
        regex_tools_require(
            isset($request[$field]) && is_string($request[$field]),
            "The PHP bridge field {$field} must be text.",
        );
    }
    regex_tools_require(
        preg_match('//u', $request['pattern']) === 1
            && preg_match('//u', $request['subject']) === 1,
        'PHP preg inputs must be valid UTF-8.',
        'invalid-utf8',
    );
    regex_tools_require(
        regex_tools_within_utf16_limit(
            $request['pattern'],
            REGEX_TOOLS_MAX_PATTERN_UTF16,
        ),
        'The PHP pattern exceeds the UTF-16 limit.',
        'pattern-limit',
    );
    $flags = $request['flags'];
    regex_tools_require(
        strspn($flags, REGEX_TOOLS_FLAGS) === strlen($flags)
            && count(array_unique(str_split($flags))) === strlen($flags),
        'The PHP bridge flags are invalid.',
        'invalid-flags',
    );
    $options = $request['options'] ?? [];
    regex_tools_require(
        is_array($options) && count($options) === 0,
        'The PHP preg engine does not accept options.',
        'invalid-options',
    );
    regex_tools_require(
        isset($request['maximumMatches'])
            && is_int($request['maximumMatches'])
            && $request['maximumMatches'] >= 1
            && $request['maximumMatches'] <= REGEX_TOOLS_MAX_MATCHES,
        'The PHP match limit is outside the bounded contract.',
    );
    regex_tools_require(
        isset($request['maximumCaptureRows'])
            && is_int($request['maximumCaptureRows'])
            && $request['maximumCaptureRows'] >= 1
            && $request['maximumCaptureRows']
                <= REGEX_TOOLS_MAX_CAPTURE_ROWS,
        'The PHP capture-row limit is outside the bounded contract.',
    );
    regex_tools_require(
        strlen($request['subject']) <= REGEX_TOOLS_MAX_SUBJECT_BYTES,
        'The PHP subject exceeds the UTF-8 byte limit.',
    );
    if ($operation === 'replace') {
        regex_tools_require(
            isset($request['replacement'])
                && is_string($request['replacement'])
                && preg_match('//u', $request['replacement']) === 1,
            'The PHP replacement must be valid UTF-8 text.',
        );
        regex_tools_require(
            regex_tools_within_utf16_limit(
                $request['replacement'],
                REGEX_TOOLS_MAX_REPLACEMENT_UTF16,
            ),
            'The PHP replacement exceeds the UTF-16 limit.',
            'replacement-limit',
        );
        regex_tools_require(
            isset($request['maximumOutputBytes'])
                && is_int($request['maximumOutputBytes'])
                && $request['maximumOutputBytes'] >= 1
                && $request['maximumOutputBytes']
                    <= REGEX_TOOLS_MAX_OUTPUT_BYTES,
            'The PHP output limit is outside the bounded contract.',
        );
    }
}

function regex_tools_escape_delimiter(string $pattern, string $delimiter): string
{
    $escaped = '';
    $backslashes = 0;
    $length = strlen($pattern);
    for ($index = 0; $index < $length; $index++) {
        $character = $pattern[$index];
        if ($character === $delimiter) {
            if ($backslashes % 2 === 0) {
                $escaped .= '\\';
            }
            $escaped .= $character;
            $backslashes = 0;
        } else {
            $escaped .= $character;
            $backslashes = $character === '\\' ? $backslashes + 1 : 0;
        }
    }
    return $escaped;
}

function regex_tools_pattern(string $raw, string $flags): string
{
    $candidates = ['~', '#', '%', '!', '@', ';', ':', ',', '=', '`', '/'];
    $delimiter = '~';
    $body = null;
    foreach ($candidates as $candidate) {
        if (!str_contains($raw, $candidate)) {
            $delimiter = $candidate;
            $body = $raw;
            break;
        }
    }
    if ($body === null) {
        $body = regex_tools_escape_delimiter($raw, $delimiter);
    }
    $modifiers = str_replace('g', '', $flags);
    return $delimiter . $body . $delimiter . $modifiers;
}

/**
 * PHP exposes the compiled numeric capture table, but duplicate named groups
 * share one associative-array key. This conservative scanner recovers every
 * duplicate declaration only when its capture count agrees with PHP's
 * authoritative count; otherwise the native table below remains authoritative.
 *
 * @return array{
 *   groupCount: int,
 *   groupNames: list<array{0: int, 1: string}>
 * }
 */
function regex_tools_lexical_capture_names(
    string $raw,
    string $flags,
): array {
    $groupCount = 0;
    $groupNames = [];
    $inClass = false;
    $inQuote = false;
    $extended = str_contains($flags, 'x');
    $noAutoCapture = str_contains($flags, 'n');
    $length = strlen($raw);
    for ($index = 0; $index < $length; $index++) {
        $character = $raw[$index];
        if ($inQuote) {
            if (
                $character === '\\'
                && ($raw[$index + 1] ?? '') === 'E'
            ) {
                $inQuote = false;
                $index++;
            }
            continue;
        }
        if ($character === '\\') {
            if (($raw[$index + 1] ?? '') === 'Q') {
                $inQuote = true;
            }
            $index++;
            continue;
        }
        if ($inClass) {
            if ($character === ']') {
                $inClass = false;
            }
            continue;
        }
        if ($character === '[') {
            $inClass = true;
            continue;
        }
        if ($extended && $character === '#') {
            while (
                $index + 1 < $length
                && $raw[$index + 1] !== "\n"
                && $raw[$index + 1] !== "\r"
            ) {
                $index++;
            }
            continue;
        }
        if ($character !== '(') {
            continue;
        }
        if (($raw[$index + 1] ?? '') !== '?') {
            if (!$noAutoCapture) {
                $groupCount++;
            }
            continue;
        }

        $marker = $raw[$index + 2] ?? '';
        $nameStart = null;
        $nameEndMarker = null;
        if (
            $marker === '<'
            && ($raw[$index + 3] ?? '') !== '='
            && ($raw[$index + 3] ?? '') !== '!'
        ) {
            $nameStart = $index + 3;
            $nameEndMarker = '>';
        } elseif ($marker === "'") {
            $nameStart = $index + 3;
            $nameEndMarker = "'";
        } elseif (
            $marker === 'P'
            && ($raw[$index + 3] ?? '') === '<'
        ) {
            $nameStart = $index + 4;
            $nameEndMarker = '>';
        } elseif ($marker === '#') {
            while (
                $index + 1 < $length
                && $raw[$index + 1] !== ')'
            ) {
                $index++;
            }
            continue;
        }
        if ($nameStart === null || $nameEndMarker === null) {
            continue;
        }
        $nameEnd = strpos($raw, $nameEndMarker, $nameStart);
        if ($nameEnd === false) {
            continue;
        }
        $name = substr($raw, $nameStart, $nameEnd - $nameStart);
        $groupCount++;
        if ($name !== '') {
            $groupNames[] = [$groupCount, $name];
        }
        $index = $nameEnd;
    }
    return [
        'groupCount' => $groupCount,
        'groupNames' => $groupNames,
    ];
}

/**
 * @return array{
 *   groupCount: int,
 *   groupNames: list<array{0: int, 1: string}>
 * }
 */
function regex_tools_capture_metadata(string $raw, string $flags): array
{
    $pattern = regex_tools_pattern($raw, $flags);
    try {
        $compiled = preg_match($pattern, '');
    } catch (RegexToolsFailure $error) {
        throw $error;
    } catch (Throwable $error) {
        throw new RegexToolsFailure(
            'compile',
            'compile-error',
            $error->getMessage(),
        );
    }
    if ($compiled === false) {
        throw new RegexToolsFailure(
            'compile',
            'compile-error',
            preg_last_error_msg(),
        );
    }

    /*
     * Appending an empty top-level branch makes MatchData available even when
     * the expression cannot match the empty string. It does not add a capture,
     * so PHP's numeric keys remain the engine-authoritative group count.
     */
    $probePattern = regex_tools_pattern($raw . '|(?:)', $flags);
    $probe = [];
    try {
        $status = preg_match(
            $probePattern,
            '',
            $probe,
            PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
        );
    } catch (RegexToolsFailure $error) {
        throw $error;
    } catch (Throwable $error) {
        throw new RegexToolsFailure(
            'compile',
            'compile-error',
            $error->getMessage(),
        );
    }
    if ($status !== 1) {
        throw new RegexToolsFailure(
            'compile',
            'metadata-error',
            'PHP preg could not inspect the compiled capture table.',
        );
    }

    $groupCount = 0;
    $groupNames = [];
    $pendingNames = [];
    foreach ($probe as $key => $_value) {
        if (is_string($key)) {
            $pendingNames[] = $key;
            continue;
        }
        if ($key <= 0) {
            continue;
        }
        $groupCount = max($groupCount, $key);
        foreach ($pendingNames as $name) {
            $groupNames[] = [$key, $name];
        }
        $pendingNames = [];
    }
    if ($groupCount > REGEX_TOOLS_MAX_CAPTURE_GROUPS) {
        throw new RegexToolsFailure(
            'compile',
            'capture-group-limit',
            "The pattern declares {$groupCount} capture groups; "
                . 'the PHP bridge limit is '
                . REGEX_TOOLS_MAX_CAPTURE_GROUPS
                . '.',
        );
    }
    $lexical = regex_tools_lexical_capture_names($raw, $flags);
    if ($lexical['groupCount'] === $groupCount) {
        $lexicalKeys = [];
        foreach ($lexical['groupNames'] as [$number, $name]) {
            $lexicalKeys[$number . "\0" . $name] = true;
        }
        $nativeNamesAreCovered = true;
        foreach ($groupNames as [$number, $name]) {
            if (!isset($lexicalKeys[$number . "\0" . $name])) {
                $nativeNamesAreCovered = false;
                break;
            }
        }
        if ($nativeNamesAreCovered) {
            $groupNames = $lexical['groupNames'];
        }
    }
    return [
        'groupCount' => $groupCount,
        'groupNames' => $groupNames,
    ];
}

function regex_tools_is_utf8_boundary(string $subject, int $offset): bool
{
    if ($offset === 0 || $offset === strlen($subject)) {
        return true;
    }
    $byte = ord($subject[$offset]);
    return $byte < 0x80 || $byte > 0xBF;
}

function regex_tools_next_utf8_offset(string $subject, int $offset): int
{
    $length = strlen($subject);
    if ($offset >= $length) {
        return $length + 1;
    }
    $offset++;
    while ($offset < $length) {
        $byte = ord($subject[$offset]);
        if ($byte < 0x80 || $byte > 0xBF) {
            break;
        }
        $offset++;
    }
    return $offset;
}

/**
 * @param array<int|string, array{0: ?string, 1: int}> $match
 * @return array{
 *   span: array{0: int, 1: int},
 *   captures: list<null|array{0: int, 1: int}>
 * }
 */
function regex_tools_match_record(
    array $match,
    int $groupCount,
    string $subject,
): array {
    $whole = $match[0] ?? null;
    if (
        !is_array($whole)
        || count($whole) !== 2
        || !is_string($whole[0])
        || !is_int($whole[1])
        || $whole[1] < 0
    ) {
        throw new RegexToolsFailure(
            'match',
            'invalid-native-result',
            'PHP preg returned a malformed whole-match record.',
        );
    }
    $start = $whole[1];
    $end = $start + strlen($whole[0]);
    if (
        !regex_tools_is_utf8_boundary($subject, $start)
        || !regex_tools_is_utf8_boundary($subject, $end)
    ) {
        throw new RegexToolsFailure(
            'match',
            'unaligned-utf8-offset',
            'PHP preg returned a byte offset inside a UTF-8 character. '
                . 'Enable the u modifier for Unicode text.',
        );
    }
    $captures = [];
    for ($number = 1; $number <= $groupCount; $number++) {
        $capture = $match[$number] ?? null;
        if (
            !is_array($capture)
            || count($capture) !== 2
            || (!is_string($capture[0]) && $capture[0] !== null)
            || !is_int($capture[1])
        ) {
            throw new RegexToolsFailure(
                'match',
                'invalid-native-result',
                'PHP preg returned a malformed capture record.',
            );
        }
        if ($capture[0] === null || $capture[1] < 0) {
            $captures[] = null;
            continue;
        }
        $captureStart = $capture[1];
        $captureEnd = $captureStart + strlen($capture[0]);
        if (
            !regex_tools_is_utf8_boundary($subject, $captureStart)
            || !regex_tools_is_utf8_boundary($subject, $captureEnd)
        ) {
            throw new RegexToolsFailure(
                'match',
                'unaligned-utf8-offset',
                'PHP preg returned a capture offset inside a UTF-8 character. '
                    . 'Enable the u modifier for Unicode text.',
            );
        }
        $captures[] = [$captureStart, $captureEnd];
    }
    return [
        'span' => [$start, $end],
        'captures' => $captures,
    ];
}

/**
 * @return array{
 *   matches: list<array{
 *     span: array{0: int, 1: int},
 *     captures: list<null|array{0: int, 1: int}>
 *   }>,
 *   resultsTruncated: bool
 * }
 */
function regex_tools_collect(
    string $pattern,
    string $nonemptyPattern,
    string $subject,
    bool $global,
    bool $unicode,
    int $groupCount,
    int $maximumMatches,
    int $maximumCaptureRows,
): array {
    $records = [];
    $captureRows = 0;
    $offset = 0;
    $subjectBytes = strlen($subject);
    $resultsTruncated = false;
    $retryNonemptyAtStart = false;

    while ($offset <= $subjectBytes) {
        $match = [];
        try {
            $status = preg_match(
                $retryNonemptyAtStart ? $nonemptyPattern : $pattern,
                $subject,
                $match,
                PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
                $offset,
            );
        } catch (RegexToolsFailure $error) {
            throw new RegexToolsFailure(
                'match',
                'match-error',
                $error->getMessage(),
                $error->patternOffset,
            );
        } catch (Throwable $error) {
            throw new RegexToolsFailure(
                'match',
                'match-error',
                $error->getMessage(),
            );
        }
        if ($status === false) {
            throw new RegexToolsFailure(
                'match',
                'match-error',
                preg_last_error_msg(),
            );
        }
        if ($status === 0) {
            if ($retryNonemptyAtStart) {
                $retryNonemptyAtStart = false;
                $offset = $unicode
                    ? regex_tools_next_utf8_offset($subject, $offset)
                    : $offset + 1;
                continue;
            }
            break;
        }

        $rows = max(1, $groupCount);
        if (
            count($records) >= $maximumMatches
            || $captureRows + $rows > $maximumCaptureRows
        ) {
            $resultsTruncated = true;
            break;
        }
        $record = regex_tools_match_record($match, $groupCount, $subject);
        $records[] = $record;
        $captureRows += $rows;
        if (!$global) {
            break;
        }

        [$start, $end] = $record['span'];
        if ($end > $start) {
            $offset = $end;
            $retryNonemptyAtStart = false;
        } elseif ($end >= $subjectBytes) {
            break;
        } else {
            /*
             * preg_match_all retries an empty match at the same position with
             * PCRE2_NOTEMPTY_ATSTART before advancing. PHP does not expose that
             * option on preg_match, so an anchored, non-empty derived pattern
             * reproduces the retry while retaining the original captures.
             */
            $offset = $end;
            $retryNonemptyAtStart = true;
        }
    }
    return [
        'matches' => $records,
        'resultsTruncated' => $resultsTruncated,
    ];
}

/**
 * @return array{0: string, 1: int, 2: bool}
 */
function regex_tools_append_bounded(
    string &$output,
    string $fragment,
    int $maximumBytes,
    bool &$truncated,
): bool {
    if ($fragment === '') {
        return true;
    }
    if ($truncated) {
        return false;
    }
    $remaining = $maximumBytes - strlen($output);
    if (strlen($fragment) <= $remaining) {
        $output .= $fragment;
        return true;
    }
    $prefix = substr($fragment, 0, max(0, $remaining));
    while ($prefix !== '' && preg_match('//u', $prefix) !== 1) {
        $prefix = substr($prefix, 0, -1);
    }
    $output .= $prefix;
    $truncated = true;
    return false;
}

/**
 * Mirrors preg_get_backref() from php-src 8.5.8. PHP replacement references
 * consume one or two decimal digits and may use the ${n} spelling.
 *
 * @return null|array{0: int, 1: int}
 */
function regex_tools_parse_replacement_backref(
    string $replacement,
    int $index,
): ?array {
    $length = strlen($replacement);
    if ($index + 1 >= $length) {
        return null;
    }
    $walk = $index;
    $inBrace = false;
    if (
        $replacement[$walk] === '$'
        && ($replacement[$walk + 1] ?? '') === '{'
    ) {
        $inBrace = true;
        $walk++;
    }
    $walk++;
    $first = $replacement[$walk] ?? '';
    if (
        $first === ''
        || ord($first) < ord('0')
        || ord($first) > ord('9')
    ) {
        return null;
    }
    $backref = ord($first) - ord('0');
    $walk++;
    $second = $replacement[$walk] ?? '';
    if (
        $second !== ''
        && ord($second) >= ord('0')
        && ord($second) <= ord('9')
    ) {
        $backref = ($backref * 10) + ord($second) - ord('0');
        $walk++;
    }
    if ($inBrace) {
        if (($replacement[$walk] ?? '') !== '}') {
            return null;
        }
        $walk++;
    }
    return [$backref, $walk];
}

/**
 * The parser is a direct, fixed translation of PHP 8.5.8's replacement loop
 * and preg_get_backref(). Parsing once avoids repeatedly walking a large
 * template for its literal spans.
 *
 * @return list<array{0: 'literal'|'backref', 1: string|int}>
 */
function regex_tools_replacement_tokens(string $replacement): array
{
    $tokens = [];
    $literal = '';
    $walkLast = '';
    $index = 0;
    $length = strlen($replacement);
    while ($index < $length) {
        $character = $replacement[$index];
        if ($character === '\\' || $character === '$') {
            if ($walkLast === '\\') {
                $literal[strlen($literal) - 1] = $character;
                $index++;
                $walkLast = '';
                continue;
            }
            $parsed = regex_tools_parse_replacement_backref(
                $replacement,
                $index,
            );
            if ($parsed !== null) {
                if ($literal !== '') {
                    $tokens[] = ['literal', $literal];
                    $literal = '';
                }
                [$backref, $index] = $parsed;
                $tokens[] = ['backref', $backref];
                continue;
            }
        }
        $literal .= $character;
        $index++;
        $walkLast = $character;
    }
    if ($literal !== '') {
        $tokens[] = ['literal', $literal];
    }
    return $tokens;
}

/**
 * Applies replacement only to the authoritative records returned to the app.
 * This is the cross-engine partial-result contract: once a match/capture cap
 * is reached, the unprocessed tail remains unchanged. Every append is clipped
 * before allocation can exceed the requested UTF-8 byte limit.
 *
 * @param list<array{
 *   span: array{0: int, 1: int},
 *   captures: list<null|array{0: int, 1: int}>
 * }> $records
 * @param list<array{0: 'literal'|'backref', 1: string|int}> $tokens
 * @return array{0: string, 1: int, 2: bool}
 */
function regex_tools_replace_bounded(
    string $subject,
    array $records,
    array $tokens,
    int $maximumBytes,
): array {
    $output = '';
    $truncated = false;
    $cursor = 0;
    foreach ($records as $record) {
        [$start, $end] = $record['span'];
        if (!regex_tools_append_bounded(
            $output,
            substr($subject, $cursor, $start - $cursor),
            $maximumBytes,
            $truncated,
        )) {
            break;
        }
        foreach ($tokens as [$kind, $value]) {
            if ($kind === 'literal') {
                $fragment = $value;
            } else {
                $number = $value;
                $range = $number === 0
                    ? $record['span']
                    : ($record['captures'][$number - 1] ?? null);
                $fragment = $range === null
                    ? ''
                    : substr($subject, $range[0], $range[1] - $range[0]);
            }
            if (!regex_tools_append_bounded(
                $output,
                $fragment,
                $maximumBytes,
                $truncated,
            )) {
                break 2;
            }
        }
        $cursor = $end;
    }
    if (!$truncated) {
        regex_tools_append_bounded(
            $output,
            substr($subject, $cursor),
            $maximumBytes,
            $truncated,
        );
    }
    return [$output, strlen($output), $truncated];
}

/**
 * @return array<string, mixed>
 */
function regex_tools_identity(): array
{
    $test = [];
    $matched = preg_match(
        '~(?<word>é+)~u',
        '😀éé',
        $test,
        PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
    );
    $replaced = preg_replace('~(?<word>é+)~u', '[$1]', '😀éé');
    $parityPattern = '~(a)(b)?~u';
    $paritySubject = 'zab a';
    $parityRecords = regex_tools_collect(
        $parityPattern,
        '~\\G(?:(a)(b)?)(?!\\G)~u',
        $paritySubject,
        true,
        true,
        2,
        100,
        100,
    );
    $parityReplacement = '$2:$1/${1}/\\1/\\\\1/\\$1/$99/${1x}';
    [$bridgeReplacement, $_bridgeBytes, $bridgeTruncated] =
        regex_tools_replace_bounded(
            $paritySubject,
            $parityRecords['matches'],
            regex_tools_replacement_tokens($parityReplacement),
            4_096,
        );
    $nativeReplacement = preg_replace(
        $parityPattern,
        $parityReplacement,
        $paritySubject,
    );
    $pcreVersion = explode(' ', PCRE_VERSION, 2)[0];
    $selfTest = $matched === 1
        && ($test[0][1] ?? null) === 4
        && strlen($test[0][0] ?? '') === 4
        && ($test['word'][1] ?? null) === 4
        && $replaced === '😀[éé]'
        && !$bridgeTruncated
        && $bridgeReplacement === $nativeReplacement;
    return [
        'abi' => REGEX_TOOLS_ABI,
        'ok' => true,
        'identity' => [
            'bridge' => 'regex-tools-php-json',
            'bridgeVersion' => REGEX_TOOLS_ABI,
            'phpVersion' => PHP_VERSION,
            'phpSapi' => PHP_SAPI,
            'pcreVersion' => $pcreVersion,
            'pcreVersionFull' => PCRE_VERSION,
            'integerBytes' => PHP_INT_SIZE,
            'offsetUnit' => 'utf8-byte',
            'selfTest' => $selfTest,
        ],
    ];
}

/**
 * @return array<string, mixed>
 */
function regex_tools_main(): array
{
    $request = regex_tools_request();
    if (($request['operation'] ?? null) === 'identity') {
        return regex_tools_identity();
    }
    regex_tools_validate_request($request);

    $rawPattern = $request['pattern'];
    $flags = $request['flags'];
    $subject = $request['subject'];
    $metadata = regex_tools_capture_metadata($rawPattern, $flags);
    $pattern = regex_tools_pattern($rawPattern, $flags);
    $nonemptyPattern = regex_tools_pattern(
        '\\G(?:' . $rawPattern . ')(?!\\G)',
        $flags,
    );
    $collected = regex_tools_collect(
        $pattern,
        $nonemptyPattern,
        $subject,
        str_contains($flags, 'g'),
        str_contains($flags, 'u'),
        $metadata['groupCount'],
        $request['maximumMatches'],
        $request['maximumCaptureRows'],
    );
    $result = [
        'abi' => REGEX_TOOLS_ABI,
        'ok' => true,
        'groupCount' => $metadata['groupCount'],
        'groupNames' => $metadata['groupNames'],
        'matches' => $collected['matches'],
        'resultsTruncated' => $collected['resultsTruncated'],
    ];
    if ($request['operation'] === 'execute') {
        return $result;
    }

    [$bounded, $outputBytes, $outputTruncated] =
        regex_tools_replace_bounded(
            $subject,
            $collected['matches'],
            regex_tools_replacement_tokens($request['replacement']),
            $request['maximumOutputBytes'],
        );
    /*
     * Base64 keeps the JSON envelope within a predictable 4/3 expansion.
     * Emitting arbitrary output directly as JSON could expand control bytes
     * sixfold before the supervising worker has a chance to enforce its cap.
     */
    $result['outputBase64'] = base64_encode($bounded);
    $result['outputBytes'] = $outputBytes;
    $result['outputTruncated'] = $outputTruncated;
    return $result;
}

try {
    $response = regex_tools_main();
} catch (Throwable $error) {
    $response = regex_tools_failure($error);
}

try {
    echo json_encode(
        $response,
        JSON_THROW_ON_ERROR
            | JSON_UNESCAPED_SLASHES
            | JSON_UNESCAPED_UNICODE,
    );
} catch (Throwable $error) {
    echo '{"abi":1,"ok":false,"phase":"bridge","code":"encoding-error",'
        . '"message":"The PHP bridge response could not be encoded."}';
}
