# frozen_string_literal: true

# Fixed bridge evaluated once inside the bundled CRuby WebAssembly runtime.
#
# JavaScript writes one bounded, length-prefixed request to the worker-local
# WASI memory filesystem. User-controlled pattern, subject and replacement
# strings are never interpolated into Ruby or JavaScript source. Replacement
# matching is driven by CRuby String#gsub/String#sub block iteration, while a
# fixed streaming implementation of CRuby 4.0.0's rb_reg_regsub grammar writes
# only the requested UTF-8 prefix to a separate worker-local output file.
module RegexToolsRubyBridge
  ABI_VERSION = 2
  MAXIMUM_CAPTURE_GROUPS = 1_000
  MAXIMUM_ERROR_CHARACTERS = 1_024
  MAXIMUM_REQUEST_BYTES = 18 * 1024 * 1024
  MAXIMUM_RESPONSE_BYTES = 16 * 1024 * 1024
  MAXIMUM_MATCHES = 10_000
  MAXIMUM_CAPTURE_ROWS = 100_000
  MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024
  REQUEST_FIELD_COUNT = 8
  REQUEST_FILE = "/regex-tools-request.bin"
  OUTPUT_FILE = "/regex-tools-output.bin"
  REQUEST_MAGIC = "RGXRUBY1".b
  REPLACEMENT_STOP = :regex_tools_replacement_stop
  HEX_DIGITS = "0123456789abcdef"

  class ReplacementFailure < StandardError
    attr_reader :original

    def initialize(original)
      @original = original
      super(original.message)
      set_backtrace(original.backtrace)
    end
  end

  class BoundedUtf8Output
    attr_reader :bytes

    def initialize(maximum_bytes)
      @maximum_bytes = maximum_bytes
      @value = String.new(capacity: [maximum_bytes, 64 * 1024].min)
      @value.force_encoding(Encoding::UTF_8)
      @bytes = 0
      @truncated = false
    end

    def append(value, byte_start = 0, byte_length = nil)
      byte_length ||= value.bytesize - byte_start
      return true if byte_length <= 0
      return false if @truncated

      remaining = @maximum_bytes - @bytes
      if byte_length <= remaining
        @value << value.byteslice(byte_start, byte_length)
        @bytes += byte_length
        return true
      end

      prefix = value.byteslice(byte_start, remaining)
      prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
      @value << prefix
      @bytes += prefix.bytesize
      @truncated = true
      false
    end

    def truncated?
      @truncated
    end

    def value
      @value
    end
  end

  class << self
    def run
      reset_output
      encode_json(run_record(*read_request))
    rescue StandardError => error
      begin
        reset_output
      rescue StandardError
        # The original bridge error is more useful than a secondary cleanup
        # failure. The adapter will still reject a stale output byte count.
      end
      encode_json(error_record("bridge", "bridge-error", error))
    end

    private

    def reset_output
      File.binwrite(OUTPUT_FILE, "".b)
    end

    def read_request
      data = File.binread(REQUEST_FILE)
      unless data.bytesize <= MAXIMUM_REQUEST_BYTES &&
             data.byteslice(0, REQUEST_MAGIC.bytesize) == REQUEST_MAGIC
        raise ArgumentError, "Invalid Ruby bridge request."
      end

      offset = REQUEST_MAGIC.bytesize
      fields = REQUEST_FIELD_COUNT.times.map do
        raise ArgumentError, "Truncated Ruby bridge request." if offset + 4 > data.bytesize

        length = data.getbyte(offset) << 24 |
                 data.getbyte(offset + 1) << 16 |
                 data.getbyte(offset + 2) << 8 |
                 data.getbyte(offset + 3)
        offset += 4
        raise ArgumentError, "Truncated Ruby bridge field." if offset + length > data.bytesize

        field = data.byteslice(offset, length)
        offset += length
        field.force_encoding(Encoding::UTF_8)
        raise ArgumentError, "Invalid UTF-8 Ruby bridge field." unless field.valid_encoding?

        field
      end
      raise ArgumentError, "Trailing Ruby bridge request data." unless offset == data.bytesize

      fields
    end

    def bounded_integer(value, label, maximum)
      text = value.to_s
      unless /\A[1-9][0-9]*\z/.match?(text)
        raise ArgumentError, "#{label} must be a positive decimal integer."
      end
      number = text.to_i
      unless number <= maximum
        raise ArgumentError, "#{label} exceeds the Ruby bridge limit."
      end
      number
    end

    def run_record(
      operation_value,
      pattern_value,
      flags_value,
      subject_value,
      maximum_matches_value,
      maximum_capture_rows_value,
      replacement_value,
      maximum_output_bytes_value
    )
      operation = operation_value.to_s
      return identity if operation == "identity"

      unless operation == "execute" || operation == "replace"
        return error_record(
          "bridge",
          "invalid-operation",
          "Unsupported Ruby bridge operation."
        )
      end

      pattern = pattern_value.to_s
      flags = flags_value.to_s
      subject = subject_value.to_s
      replacement = replacement_value.to_s
      maximum_matches = bounded_integer(
        maximum_matches_value,
        "maximumMatches",
        MAXIMUM_MATCHES
      )
      maximum_capture_rows = bounded_integer(
        maximum_capture_rows_value,
        "maximumCaptureRows",
        MAXIMUM_CAPTURE_ROWS
      )
      maximum_output_bytes = bounded_integer(
        maximum_output_bytes_value,
        "maximumOutputBytes",
        MAXIMUM_OUTPUT_BYTES
      )
      unless /\A[gimx]*\z/.match?(flags) && flags.each_char.uniq.length == flags.length
        return error_record(
          "bridge",
          "invalid-flags",
          "The Ruby bridge received unsupported or duplicate flags."
        )
      end

      regexp = compile(pattern, flags)
      return regexp if regexp.is_a?(Hash)

      group_count = capture_group_count(regexp)
      if group_count > MAXIMUM_CAPTURE_GROUPS
        return error_record(
          "compile",
          "capture-group-limit",
          "The pattern declares #{group_count} capture groups; " \
            "the bridge limit is #{MAXIMUM_CAPTURE_GROUPS}."
        )
      end

      begin
        collected = collect_matches(
          regexp,
          subject,
          flags.include?("g"),
          group_count,
          maximum_matches,
          maximum_capture_rows,
          operation == "replace" ? replacement : nil,
          maximum_output_bytes
        )
      rescue ReplacementFailure => error
        return error_record(
          "replacement",
          "replacement-error",
          error.original
        )
      rescue StandardError => error
        return error_record("match", "match-error", error)
      end

      result = {
        "abi" => ABI_VERSION,
        "ok" => true,
        "groupCount" => group_count,
        "groupNames" => capture_group_names(regexp),
        "matches" => collected["matches"],
        "resultsTruncated" => collected["resultsTruncated"]
      }
      return result if operation == "execute"

      File.binwrite(OUTPUT_FILE, collected["output"])
      result.merge(
        "outputBytes" => collected["outputBytes"],
        "outputTruncated" => collected["outputTruncated"]
      )
    rescue StandardError => error
      error_record("bridge", "bridge-error", error)
    end

    # RbValue#toJS delegates Hash/Array conversion to the ruby.wasm `js` gem,
    # whose generic conversion path constructs objects through JS.eval. The
    # Toolbox CSP deliberately disallows that capability. Return one inert,
    # metadata-only JSON string, assembled incrementally without per-value
    # arrays or the potentially 6x JSON escaping amplification of output text.
    def encode_json(value)
      output = String.new(capacity: 4 * 1024)
      output.force_encoding(Encoding::UTF_8)
      append_json(output, value)
      if output.bytesize > MAXIMUM_RESPONSE_BYTES
        raise RangeError, "Ruby bridge metadata exceeds its response limit."
      end
      output
    end

    def append_json(output, value)
      case value
      when Hash
        output << "{"
        first = true
        value.each do |key, item|
          output << "," unless first
          first = false
          append_json_string(output, key.to_s)
          output << ":"
          append_json(output, item)
        end
        output << "}"
      when Array
        output << "["
        value.each_with_index do |item, index|
          output << "," unless index.zero?
          append_json(output, item)
        end
        output << "]"
      when String
        append_json_string(output, value)
      when Integer
        output << value.to_s
      when true
        output << "true"
      when false
        output << "false"
      when nil
        output << "null"
      else
        append_json_string(output, value.to_s)
      end
      if output.bytesize > MAXIMUM_RESPONSE_BYTES
        raise RangeError, "Ruby bridge metadata exceeds its response limit."
      end
    end

    def append_json_string(output, value)
      text = value.valid_encoding? \
        ? value \
        : value.encode(
          Encoding::UTF_8,
          invalid: :replace,
          undef: :replace,
          replace: "\uFFFD"
        )
      output << "\""
      text.each_codepoint do |codepoint|
        case codepoint
        when 0x08 then output << "\\b"
        when 0x09 then output << "\\t"
        when 0x0A then output << "\\n"
        when 0x0C then output << "\\f"
        when 0x0D then output << "\\r"
        when 0x22 then output << "\\\""
        when 0x5C then output << "\\\\"
        when 0x00..0x1F
          output << "\\u00"
          output << HEX_DIGITS.getbyte(codepoint >> 4)
          output << HEX_DIGITS.getbyte(codepoint & 0x0F)
        else
          output << codepoint
        end
      end
      output << "\""
    end

    def identity
      verify_replacement_parity
      {
        "abi" => ABI_VERSION,
        "ok" => true,
        "identity" => {
          "implementation" => RUBY_ENGINE,
          "rubyVersion" => RUBY_VERSION,
          "platform" => RUBY_PLATFORM,
          "defaultExternalEncoding" => Encoding.default_external.name
        }
      }
    end

    def compile(pattern, flags)
      options = 0
      options |= Regexp::IGNORECASE if flags.include?("i")
      options |= Regexp::MULTILINE if flags.include?("m")
      options |= Regexp::EXTENDED if flags.include?("x")
      Regexp.new(pattern, options)
    rescue RegexpError => error
      error_record("compile", "compile-error", error)
    rescue StandardError => error
      error_record("compile", "compile-error", error)
    end

    def capture_group_count(regexp)
      # Alternating with an empty non-capturing branch makes a match available
      # without changing the original capture numbering.
      Regexp.union(regexp, /(?:)/).match("").length - 1
    end

    def capture_group_names(regexp)
      regexp.named_captures.flat_map do |name, numbers|
        numbers.map { |number| [number, name] }
      end.sort_by { |number, name| [number, name] }
    end

    def match_record(match, group_count)
      captures = (1..group_count).map do |group_number|
        start_offset = match.begin(group_number)
        start_offset.nil? \
          ? nil \
          : [start_offset, match.end(group_number)]
      end
      {
        "span" => [match.begin(0), match.end(0)],
        "captures" => captures
      }
    end

    def collect_matches(
      regexp,
      subject,
      global,
      group_count,
      maximum_matches,
      maximum_capture_rows,
      replacement = nil,
      maximum_output_bytes = 1
    )
      records = []
      capture_rows = 0
      results_truncated = false
      output = replacement.nil? \
        ? nil \
        : BoundedUtf8Output.new(maximum_output_bytes)
      last_replacement_end = 0

      callback = proc do
        match = Regexp.last_match
        rows_for_match = [1, group_count].max
        if records.length >= maximum_matches ||
           capture_rows + rows_for_match > maximum_capture_rows
          results_truncated = true
          throw REPLACEMENT_STOP
        end

        records << match_record(match, group_count)
        capture_rows += rows_for_match
        unless output.nil? || output.truncated?
          match_start = match.bytebegin(0)
          output.append(
            subject,
            last_replacement_end,
            match_start - last_replacement_end
          )
          unless output.truncated?
            begin
              append_replacement(
                output,
                replacement,
                subject,
                match,
                regexp.names.empty?
              )
            rescue StandardError => error
              raise ReplacementFailure, error
            end
          end
        end
        last_replacement_end = match.byteend(0)
        ""
      end

      catch(REPLACEMENT_STOP) do
        global ? subject.gsub(regexp, &callback) : subject.sub(regexp, &callback)
      end

      unless output.nil? || output.truncated?
        output.append(
          subject,
          last_replacement_end,
          subject.bytesize - last_replacement_end
        )
      end

      result = {
        "matches" => records,
        "resultsTruncated" => results_truncated
      }
      unless output.nil?
        result["output"] = output.value
        result["outputBytes"] = output.bytes
        result["outputTruncated"] = output.truncated?
      end
      result
    end

    # Mirrors CRuby 4.0.0 rb_reg_regsub: \1..\9, \k<name>, \0, \&, \`,
    # \', \+, \\ and literal preservation for every other escaped character.
    # Match selection and byte offsets remain native MatchData results.
    def append_replacement(
      output,
      replacement,
      subject,
      match,
      numeric_backrefs_active
    )
      bytes = replacement.b
      cursor = 0
      while cursor < bytes.bytesize && !output.truncated?
        slash = bytes.index("\\", cursor)
        if slash.nil?
          output.append(replacement, cursor, bytes.bytesize - cursor)
          break
        end
        output.append(replacement, cursor, slash - cursor) if slash > cursor
        break if output.truncated?

        if slash + 1 >= bytes.bytesize
          output.append(replacement, slash, 1)
          break
        end

        marker = bytes.getbyte(slash + 1)
        cursor = slash + 2
        case marker
        when 0x31..0x39
          append_capture(
            output,
            subject,
            match,
            marker - 0x30
          ) if numeric_backrefs_active
        when 0x6B # k
          if cursor < bytes.bytesize && bytes.getbyte(cursor) == 0x3C # <
            name_start = cursor + 1
            name_end = bytes.index(">", name_start)
            if name_end.nil?
              raise RuntimeError, "invalid group name reference format"
            end
            name = replacement.byteslice(name_start, name_end - name_start)
            append_capture(output, subject, match, name)
            cursor = name_end + 1
          else
            output.append(replacement, slash, 2)
          end
        when 0x30, 0x26 # 0, &
          append_capture(output, subject, match, 0)
        when 0x60 # `
          output.append(subject, 0, match.bytebegin(0))
        when 0x27 # '
          match_end = match.byteend(0)
          output.append(subject, match_end, subject.bytesize - match_end)
        when 0x2B # +
          group_number = match.length - 1
          group_number -= 1 while group_number > 0 && match.bytebegin(group_number).nil?
          append_capture(output, subject, match, group_number) if group_number > 0
        when 0x5C # \
          output.append(replacement, slash + 1, 1)
        else
          if marker >= 0x80
            cursor += 1 while cursor < bytes.bytesize &&
                              (bytes.getbyte(cursor) & 0xC0) == 0x80
          end
          output.append(replacement, slash, cursor - slash)
        end
      end
    end

    def append_capture(output, subject, match, group)
      if group.is_a?(Integer) && group >= match.length
        return
      end
      byte_start, byte_end = match.byteoffset(group)
      return if byte_start.nil?

      output.append(subject, byte_start, byte_end - byte_start)
    end

    def verify_replacement_parity
      return if @replacement_parity_verified

      slash = 92.chr
      fixtures = [
        [
          /(a)(b)?/,
          "ab a",
          [
            "<",
            "#{slash}0",
            "|#{slash}&",
            "|#{slash}1",
            "|#{slash}2",
            "|#{slash}9",
            "|#{slash}+",
            "|#{slash}#{slash}",
            "|#{slash}q",
            "|#{slash}`",
            "|#{slash}'",
            ">"
          ].join,
          true
        ],
        [
          /(?<left>a)(?<right>b)?/,
          "ab a",
          "<#{slash}k<left>|#{slash}k<right>|#{slash}1>",
          true
        ],
        [/(é)/, "xéy", "#{slash}é/#{slash}1", false]
      ]
      fixtures.each do |regexp, subject, replacement, global|
        native = global \
          ? subject.gsub(regexp, replacement) \
          : subject.sub(regexp, replacement)
        bounded = collect_matches(
          regexp,
          subject,
          global,
          capture_group_count(regexp),
          MAXIMUM_MATCHES,
          MAXIMUM_CAPTURE_ROWS,
          replacement,
          1024 * 1024
        )
        unless !bounded["resultsTruncated"] &&
               !bounded["outputTruncated"] &&
               bounded["output"] == native
          raise "Bounded Ruby replacement semantics failed the CRuby parity test."
        end
      end
      @replacement_parity_verified = true
    end

    def error_record(phase, code, error)
      message = error.is_a?(Exception) ? error.message : error.to_s
      if message.length > MAXIMUM_ERROR_CHARACTERS
        message = "#{message[0, MAXIMUM_ERROR_CHARACTERS - 1]}…"
      end
      {
        "abi" => ABI_VERSION,
        "ok" => false,
        "phase" => phase,
        "code" => code,
        "message" => message
      }
    end
  end
end

RegexToolsRubyBridge
