package RegexTools;

use strict;
use warnings;
use utf8;
use Cpanel::JSON::XS ();
no re 'eval';

our $BRIDGE_ABI_VERSION = 1;
our $REQUEST_PATH = '/tmp/regex-tools-request.json';
our $RESPONSE_PATH = '/tmp/regex-tools-response.json';
our $OUTPUT_PATH = '/tmp/regex-tools-output.bin';

my $JSON = Cpanel::JSON::XS->new->utf8->canonical;
my $TRUE = Cpanel::JSON::XS::true;
my $FALSE = Cpanel::JSON::XS::false;

my $MAXIMUM_PATTERN_CHARACTERS = 64 * 1024;
my $MAXIMUM_SUBJECT_BYTES = 16 * 1024 * 1024;
my $MAXIMUM_REPLACEMENT_CHARACTERS = 64 * 1024;
my $MAXIMUM_MATCHES = 10_000;
my $MAXIMUM_CAPTURE_ROWS = 100_000;
my $MAXIMUM_CAPTURE_GROUPS = 1_000;
my $MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024;
my $MAXIMUM_ERROR_CHARACTERS = 1_024;
my $OUTPUT_CHUNK_CHARACTERS = 4_096;
my $MAXIMUM_REQUEST_BYTES = 6 * (
    $MAXIMUM_SUBJECT_BYTES
        + $MAXIMUM_PATTERN_CHARACTERS
        + $MAXIMUM_REPLACEMENT_CHARACTERS
) + 16 * 1024;
my $MAXIMUM_RESPONSE_BYTES = 8 * 1024 * 1024;

sub _bounded_message {
    my ($value) = @_;
    $value = defined($value) ? "$value" : 'Unknown Perl bridge error';
    $value =~ s/\s+at \Q@{[$0]}\E line \d+\.?\s*\z//;
    return length($value) <= $MAXIMUM_ERROR_CHARACTERS
        ? $value
        : substr($value, 0, $MAXIMUM_ERROR_CHARACTERS - 1) . "\x{2026}";
}

sub _utf8_bytes {
    my ($value) = @_;
    my $encoded = $value;
    utf8::encode($encoded);
    return $encoded;
}

sub _failure {
    my ($phase, $code, $message) = @_;
    return {
        abi => $BRIDGE_ABI_VERSION,
        ok => $FALSE,
        phase => $phase,
        code => $code,
        message => _bounded_message($message),
    };
}

sub _is_plain_hash {
    my ($value) = @_;
    return ref($value) eq 'HASH';
}

sub _has_exact_keys {
    my ($value, @expected) = @_;
    return 0 if !_is_plain_hash($value);
    return join("\0", sort keys %$value)
        eq join("\0", sort @expected);
}

sub _is_integer_in_range {
    my ($value, $minimum, $maximum) = @_;
    return defined($value)
        && !ref($value)
        && "$value" =~ /\A(?:0|[1-9][0-9]*)\z/
        && $value >= $minimum
        && $value <= $maximum;
}

sub _read_request {
    open my $input, '<:raw', $REQUEST_PATH
        or die "Unable to open the fixed bridge request file: $!";
    local $/;
    my $encoded = <$input>;
    close $input or die "Unable to close the fixed bridge request file: $!";
    die 'The fixed bridge request exceeds its byte limit.'
        if !defined($encoded) || length($encoded) > $MAXIMUM_REQUEST_BYTES;
    return $JSON->decode($encoded);
}

sub _write_response {
    my ($response) = @_;
    my $encoded = $JSON->encode($response);
    die 'The fixed bridge response exceeds its byte limit.'
        if length($encoded) > $MAXIMUM_RESPONSE_BYTES;
    open my $output, '>:raw', $RESPONSE_PATH
        or die "Unable to open the fixed bridge response file: $!";
    print {$output} $encoded
        or die "Unable to write the fixed bridge response file: $!";
    close $output
        or die "Unable to close the fixed bridge response file: $!";
}

sub _contains_code_assertion {
    my ($pattern) = @_;
    my $escaped = 0;
    my $character_class = 0;
    my $quoted = 0;
    for (my $index = 0; $index < length($pattern); $index++) {
        my $current = substr($pattern, $index, 1);
        if ($quoted) {
            if ($current eq '\\'
                    && substr($pattern, $index + 1, 1) eq 'E') {
                $quoted = 0;
                $index++;
            }
            next;
        }
        if ($escaped) {
            $escaped = 0;
            next;
        }
        if ($current eq '\\') {
            if (substr($pattern, $index + 1, 1) eq 'Q') {
                $quoted = 1;
                $index++;
            }
            else {
                $escaped = 1;
            }
            next;
        }
        if ($current eq '[' && !$character_class) {
            $character_class = 1;
            next;
        }
        if ($current eq ']' && $character_class) {
            $character_class = 0;
            next;
        }
        next if $character_class;
        return 1 if substr($pattern, $index, 3) eq '(?{';
        return 1 if substr($pattern, $index, 4) eq '(??{';
    }
    return 0;
}

sub _validate_flags {
    my ($flags) = @_;
    return 'Flags must be a string.' if !defined($flags) || ref($flags);
    my %seen;
    for my $flag (split //, $flags) {
        return 'The request contains an unsupported or duplicate Perl flag.'
            if $flag !~ /\A[gimsxnadlu]\z/ || $seen{$flag}++;
    }
    my $character_rules = grep { $seen{$_} } qw(a d l u);
    return 'Perl character-set flags a, d, l and u are mutually exclusive.'
        if $character_rules > 1;
    return;
}

sub _validate_request {
    my ($request) = @_;
    return _failure('bridge', 'invalid-request', 'The request must be a JSON object.')
        if !_is_plain_hash($request);
    return _failure('bridge', 'abi-mismatch', 'The bridge ABI version is unsupported.')
        if !defined($request->{abi})
            || ref($request->{abi})
            || $request->{abi} != $BRIDGE_ABI_VERSION;
    if (defined($request->{operation})
            && !ref($request->{operation})
            && $request->{operation} eq 'identity') {
        return _failure(
            'bridge',
            'invalid-request',
            'The identity request contains missing or unexpected fields.',
        ) if !_has_exact_keys($request, qw(abi operation));
        return;
    }
    return _failure('bridge', 'invalid-operation', 'The bridge operation is unsupported.')
        if !defined($request->{operation})
            || ref($request->{operation})
            || ($request->{operation} ne 'execute'
                && $request->{operation} ne 'replace');
    my @request_fields = qw(
        abi
        flags
        maximumCaptureRows
        maximumMatches
        operation
        options
        pattern
        scanAll
        subject
    );
    push @request_fields, qw(maximumOutputBytes replacement)
        if $request->{operation} eq 'replace';
    return _failure(
        'bridge',
        'invalid-request',
        'The request contains missing or unexpected fields.',
    ) if !_has_exact_keys($request, @request_fields);
    return _failure('bridge', 'invalid-request', 'Pattern and subject must be strings.')
        if !defined($request->{pattern})
            || ref($request->{pattern})
            || !defined($request->{subject})
            || ref($request->{subject});
    return _failure('bridge', 'pattern-limit', 'The pattern exceeds the bridge limit.')
        if length($request->{pattern}) > $MAXIMUM_PATTERN_CHARACTERS;
    return _failure('bridge', 'subject-limit', 'The subject exceeds the bridge limit.')
        if length(_utf8_bytes($request->{subject})) > $MAXIMUM_SUBJECT_BYTES;
    return _failure('bridge', 'unsupported-option', 'Perl does not accept engine options.')
        if !_is_plain_hash($request->{options})
            || keys(%{$request->{options}});
    my $flag_error = _validate_flags($request->{flags});
    return _failure('bridge', 'unsupported-flag', $flag_error) if $flag_error;
    return _failure(
        'bridge',
        'result-limit',
        'The requested result bounds are outside the bridge contract.',
    ) if !_is_integer_in_range(
        $request->{maximumMatches},
        1,
        $MAXIMUM_MATCHES,
    ) || !_is_integer_in_range(
        $request->{maximumCaptureRows},
        1,
        $MAXIMUM_CAPTURE_ROWS,
    );
    return _failure(
        'compile',
        'code-assertion-disabled',
        'Perl code assertions (?{...}) and (??{...}) are disabled.',
    ) if _contains_code_assertion($request->{pattern});
    if ($request->{operation} eq 'execute') {
        return _failure(
            'bridge',
            'invalid-request',
            'Execution requests cannot contain replacement fields.',
        ) if exists($request->{replacement})
            || exists($request->{maximumOutputBytes});
        return;
    }
    return _failure(
        'bridge',
        'invalid-request',
        'Replacement requests require a string template and output bound.',
    ) if !defined($request->{replacement})
        || ref($request->{replacement})
        || !_is_integer_in_range(
            $request->{maximumOutputBytes},
            1,
            $MAXIMUM_OUTPUT_BYTES,
        );
    return _failure(
        'bridge',
        'replacement-limit',
        'The replacement template exceeds the bridge limit.',
    ) if length($request->{replacement}) > $MAXIMUM_REPLACEMENT_CHARACTERS;
    return;
}

sub _compile_pattern {
    my ($pattern, $flags) = @_;
    my $modifiers = join '', grep { index($flags, $_) >= 0 }
        qw(i m s x n a d l u);
    my $wrapped = length($modifiers)
        ? "(?$modifiers:$pattern)"
        : "(?:$pattern)";
    my $compiled;
    my $error;
    {
        local $SIG{__WARN__} = sub { };
        local $@;
        $compiled = eval { qr/$wrapped/ };
        $error = $@;
    }
    return ($compiled, $error);
}

sub _capture_group_count {
    my ($compiled) = @_;
    my $probe = qr/(?:$compiled)|()/;
    '' =~ $probe;
    return $#- - 1;
}

sub _snapshot_match {
    my ($group_count, $starts, $ends) = @_;
    my @captures;
    for my $group (1 .. $group_count) {
        push @captures, defined($starts->[$group])
                && $starts->[$group] >= 0
            ? [0 + $starts->[$group], 0 + $ends->[$group]]
            : undef;
    }
    return {
        span => [0 + $starts->[0], 0 + $ends->[0]],
        captures => \@captures,
    };
}

sub _push_literal_token {
    my ($tokens, $literal) = @_;
    return if !length($$literal);
    push @$tokens, ['literal', $$literal];
    $$literal = '';
}

sub _tokenize_replacement {
    my ($template, $group_count) = @_;
    my @tokens;
    my $literal = '';
    my $cursor = 0;
    while ($cursor < length($template)) {
        my $current = substr($template, $cursor, 1);
        if ($current ne '$') {
            $literal .= $current;
            $cursor++;
            next;
        }
        my $next = substr($template, $cursor + 1, 1);
        if ($next eq '$') {
            _push_literal_token(\@tokens, \$literal);
            push @tokens, ['literal', '$'];
            $cursor += 2;
            next;
        }
        if (substr($template, $cursor + 1) =~ /\A([0-9]+)/) {
            my $digits = $1;
            my $canonical = $digits;
            $canonical =~ s/\A0+(?=[0-9])//;
            my $number = length($canonical) <= length("$group_count")
                    && $canonical <= $group_count
                ? 0 + $canonical
                : undef;
            _push_literal_token(\@tokens, \$literal);
            push @tokens, ['capture', $number];
            $cursor += 1 + length($digits);
            next;
        }
        if (substr($template, $cursor + 1)
                =~ /\A\{([A-Za-z_][A-Za-z0-9_]*)\}/) {
            my $name = $1;
            _push_literal_token(\@tokens, \$literal);
            push @tokens, ['named', $name];
            $cursor += 3 + length($name);
            next;
        }
        $literal .= '$';
        $cursor++;
    }
    _push_literal_token(\@tokens, \$literal);
    return \@tokens;
}

sub _write_encoded {
    my ($state, $encoded) = @_;
    return if !length($encoded);
    print {$state->{handle}} $encoded
        or die "Unable to write the fixed bridge output file: $!";
    $state->{bytes} += length($encoded);
}

sub _append_piece {
    my ($state, $value) = @_;
    return 0 if $state->{truncated};
    return 1 if !length($value);
    my $encoded = _utf8_bytes($value);
    my $remaining = $state->{maximum} - $state->{bytes};
    if (length($encoded) <= $remaining) {
        _write_encoded($state, $encoded);
        return 1;
    }
    my ($low, $high) = (0, length($value));
    while ($low < $high) {
        my $middle = int(($low + $high + 1) / 2);
        my $prefix_bytes = length(_utf8_bytes(substr($value, 0, $middle)));
        if ($prefix_bytes <= $remaining) {
            $low = $middle;
        }
        else {
            $high = $middle - 1;
        }
    }
    _write_encoded($state, _utf8_bytes(substr($value, 0, $low)))
        if $low > 0;
    $state->{truncated} = $TRUE;
    return 0;
}

sub _append_text {
    my ($state, $value) = @_;
    return 0 if $state->{truncated};
    my $cursor = 0;
    while ($cursor < length($value)) {
        my $length = length($value) - $cursor;
        $length = $OUTPUT_CHUNK_CHARACTERS
            if $length > $OUTPUT_CHUNK_CHARACTERS;
        return 0
            if !_append_piece($state, substr($value, $cursor, $length));
        $cursor += $length;
    }
    return 1;
}

sub _append_span {
    my ($state, $subject, $start, $end) = @_;
    return 0 if $state->{truncated};
    my $cursor = $start;
    while ($cursor < $end) {
        my $length = $end - $cursor;
        $length = $OUTPUT_CHUNK_CHARACTERS
            if $length > $OUTPUT_CHUNK_CHARACTERS;
        return 0
            if !_append_piece($state, substr($subject, $cursor, $length));
        $cursor += $length;
    }
    return 1;
}

sub _resolve_named_spans {
    my ($tokens, $subject, $starts, $ends, $named_values) = @_;
    my %resolved;
    for my $token (@$tokens) {
        my ($kind, $name) = @$token;
        next if $kind ne 'named'
            || exists($resolved{$name})
            || !exists($named_values->{$name})
            || !defined($named_values->{$name});
        my $captured = $named_values->{$name};
        for my $group (1 .. $#$ends) {
            next if !defined($starts->[$group])
                || $starts->[$group] < 0
                || $ends->[$group] - $starts->[$group] != length($captured)
                || index($subject, $captured, $starts->[$group])
                    != $starts->[$group];
            $resolved{$name} = [
                0 + $starts->[$group],
                0 + $ends->[$group],
            ];
            last;
        }
        die "Unable to resolve native span for named capture $name."
            if !exists($resolved{$name});
    }
    return \%resolved;
}

sub _append_replacement_tokens {
    my ($state, $tokens, $subject, $starts, $ends, $named_spans) = @_;
    for my $token (@$tokens) {
        return 0 if $state->{truncated};
        my ($kind, $value) = @$token;
        if ($kind eq 'literal') {
            _append_text($state, $value);
        }
        elsif ($kind eq 'capture') {
            _append_span(
                $state,
                $subject,
                $starts->[$value],
                $ends->[$value],
            ) if defined($value)
                && defined($starts->[$value])
                && $starts->[$value] >= 0;
        }
        elsif ($kind eq 'named') {
            _append_span(
                $state,
                $subject,
                $named_spans->{$value}->[0],
                $named_spans->{$value}->[1],
            ) if exists($named_spans->{$value});
        }
        else {
            die 'The fixed replacement tokenizer produced an invalid token.';
        }
    }
    return !$state->{truncated};
}

sub _open_output {
    my ($maximum, $path) = @_;
    open my $handle, '>:raw', $path
        or die "Unable to open the fixed bridge output file: $!";
    return {
        handle => $handle,
        bytes => 0,
        maximum => 0 + $maximum,
        truncated => $FALSE,
    };
}

sub _close_output {
    my ($state) = @_;
    close $state->{handle}
        or die "Unable to close the fixed bridge output file: $!";
    delete $state->{handle};
}

sub _run_regex {
    my ($request) = @_;
    my ($compiled, $compile_error) = _compile_pattern(
        $request->{pattern},
        $request->{flags},
    );
    return _failure('compile', 'compile-error', $compile_error)
        if !defined($compiled);
    my $group_count = _capture_group_count($compiled);
    return _failure(
        'compile',
        'capture-group-limit',
        "The pattern declares $group_count capture groups; "
            . "the bridge limit is $MAXIMUM_CAPTURE_GROUPS.",
    ) if $group_count > $MAXIMUM_CAPTURE_GROUPS;

    my $rows_per_match = $group_count > 0 ? $group_count : 1;
    my $allowed_matches = $request->{maximumMatches};
    my $row_bound = int($request->{maximumCaptureRows} / $rows_per_match);
    $allowed_matches = $row_bound if $row_bound < $allowed_matches;

    my $subject = $request->{subject};
    my $global = index($request->{flags}, 'g') >= 0;
    my $replacement_tokens = $request->{operation} eq 'replace'
        ? _tokenize_replacement($request->{replacement}, $group_count)
        : undef;
    my @matches;
    my $results_truncated = $FALSE;
    my $replacement_state = defined($replacement_tokens)
        ? _open_output($request->{maximumOutputBytes}, $OUTPUT_PATH)
        : undef;
    $replacement_state->{cursor} = 0 if defined($replacement_state);

    pos($subject) = 0;
    while ($subject =~ /$compiled/g) {
        my @starts = @-;
        my @ends = @+;
        if (@matches >= $allowed_matches) {
            $results_truncated = $TRUE;
            last;
        }
        push @matches, _snapshot_match(
            $group_count,
            \@starts,
            \@ends,
        );

        if (defined($replacement_state)) {
            _append_span(
                $replacement_state,
                $subject,
                $replacement_state->{cursor},
                $starts[0],
            );
            if (!$replacement_state->{truncated}) {
                my $named_spans = _resolve_named_spans(
                    $replacement_tokens,
                    $subject,
                    \@starts,
                    \@ends,
                    \%+,
                );
                _append_replacement_tokens(
                    $replacement_state,
                    $replacement_tokens,
                    $subject,
                    \@starts,
                    \@ends,
                    $named_spans,
                );
            }
            $replacement_state->{cursor} = $ends[0];
            if ($replacement_state->{truncated}) {
                $results_truncated = $TRUE;
                last;
            }
        }
        last if !$global;
    }

    my $response = {
        abi => $BRIDGE_ABI_VERSION,
        ok => $TRUE,
        groupCount => 0 + $group_count,
        groupNames => [],
        matches => \@matches,
        resultsTruncated => $results_truncated,
    };
    if (defined($replacement_state)) {
        _append_span(
            $replacement_state,
            $subject,
            $replacement_state->{cursor},
            length($subject),
        );
        _close_output($replacement_state);
        $response->{outputBytes} = 0 + $replacement_state->{bytes};
        $response->{outputTruncated} = $replacement_state->{truncated};
    }
    return $response;
}

sub _render_self_test_replacement {
    my ($compiled, $subject, $template, $maximum) = @_;
    my $group_count = _capture_group_count($compiled);
    my $tokens = _tokenize_replacement($template, $group_count);
    return if $subject !~ /$compiled/;
    my @starts = @-;
    my @ends = @+;
    my $named_spans = _resolve_named_spans(
        $tokens,
        $subject,
        \@starts,
        \@ends,
        \%+,
    );
    my $encoded = '';
    my $state = _open_output($maximum, \$encoded);
    _append_span($state, $subject, 0, $starts[0]);
    _append_replacement_tokens(
        $state,
        $tokens,
        $subject,
        \@starts,
        \@ends,
        $named_spans,
    );
    _append_span($state, $subject, $ends[0], length($subject));
    _close_output($state);
    my $decoded = $encoded;
    return if !utf8::decode($decoded);
    return ($decoded, 0 + $state->{bytes}, $state->{truncated});
}

sub _self_test {
    return $FALSE if "$^V" ne 'v5.28.1';
    my ($compiled, $error) = _compile_pattern(
        '(?<word>\x{1F600}+)',
        'u',
    );
    return $FALSE if $error || !defined($compiled);
    my $subject = "x\x{1F600}\x{1F600}";
    return $FALSE if $subject !~ /$compiled/;
    return $FALSE if $-[0] != 1 || $+[0] != 3;
    return $FALSE if $+{word} ne "\x{1F600}\x{1F600}";
    my ($named, $named_bytes, $named_truncated) =
        _render_self_test_replacement(
            $compiled,
            $subject,
            '<${word}>',
            1_024,
        );
    return $FALSE if !defined($named)
        || $named ne "x<\x{1F600}\x{1F600}>"
        || $named_bytes != 11
        || $named_truncated;
    my $native_named = $subject;
    $native_named =~ s/$compiled/<$+{word}>/;
    return $FALSE if $named ne $native_named;
    my $numbered_compiled = qr/(a)(b)/;
    my ($numbered, $numbered_bytes, $numbered_truncated) =
        _render_self_test_replacement(
            $numbered_compiled,
            'ab',
            '$1-$$',
            1_024,
        );
    my $native_numbered = 'ab';
    $native_numbered =~ s/$numbered_compiled/$1-\$/;
    return $FALSE if !defined($numbered)
        || $numbered ne $native_numbered
        || $numbered_bytes != 3
        || $numbered_truncated;
    my ($greedy, $greedy_bytes, $greedy_truncated) =
        _render_self_test_replacement(
            qr/(a)(b)/,
            'ab',
            '$12/$1/$$/${missing}/$',
            1_024,
        );
    return $FALSE if !defined($greedy)
        || $greedy ne '/a/$//$'
        || $greedy_bytes != 7
        || $greedy_truncated;
    my ($bounded, $bounded_bytes, $bounded_truncated) =
        _render_self_test_replacement(
            $compiled,
            $subject,
            '<${word}>',
            5,
        );
    return $FALSE if !defined($bounded)
        || $bounded ne 'x<'
        || $bounded_bytes != 2
        || !$bounded_truncated;
    return $TRUE;
}

sub _identity {
    return {
        abi => $BRIDGE_ABI_VERSION,
        ok => $TRUE,
        identity => {
            bridge => 'regex-tools-webperl-json',
            bridgeVersion => $BRIDGE_ABI_VERSION,
            engine => 'Perl regular expressions',
            engineVersion => '5.28.1',
            perlVersion => "$^V",
            webPerlVersion => '0.09-beta',
            offsetUnit => 'code-point',
            replacementGrammar => 'literal + $$ + $n + ${name}',
            replacementTransport => 'fixed MEMFS binary output file',
            replacementParityOracle => 'fixed native s/// fixtures',
            legacy => $TRUE,
            beta => $TRUE,
            selfTest => _self_test(),
        },
    };
}

sub run_request {
    my $response;
    my $ok = eval {
        my $request = _read_request();
        my $validation_failure = _validate_request($request);
        $response = $validation_failure
            || ($request->{operation} eq 'identity'
                ? _identity()
                : _run_regex($request));
        1;
    };
    if (!$ok) {
        $response = _failure(
            'bridge',
            'bridge-error',
            $@ || 'Unknown fixed Perl bridge failure',
        );
    }
    eval { _write_response($response); 1 } or do {
        my $fallback = _failure(
            'bridge',
            'serialization-error',
            $@ || 'The fixed Perl bridge could not serialize its response.',
        );
        _write_response($fallback);
    };
    return 'ok';
}

1;
