# frozen_string_literal: true

# Skip reloading when an identical copy (e.g. the one shipped inside the Bundler
# gem) was already required from a different path, to avoid redefinition warnings.
return if defined?(Gem::YAMLSerializer)

unless defined?(Psych::VERSION)
  module Psych
    class Exception < ::RuntimeError; end
    class SyntaxError < Exception; end
    class DisallowedClass < Exception; end
    class BadAlias < Exception; end
    class AliasesNotEnabled < BadAlias; end
  end
end

module Gem
  module YAMLSerializer
    Scalar = Struct.new(:value, :tag, :anchor, keyword_init: true)

    Mapping = Struct.new(:pairs, :tag, :anchor, keyword_init: true) do
      def initialize(pairs: [], tag: nil, anchor: nil)
        super
      end
    end

    Sequence = Struct.new(:items, :tag, :anchor, keyword_init: true) do
      def initialize(items: [], tag: nil, anchor: nil)
        super
      end
    end

    AliasRef = Struct.new(:name, keyword_init: true)

    class Parser
      # A plain (unquoted) mapping key followed by ":". "#" inside a key is
      # literal unless preceded by a space (which would start a comment).
      MAPPING_KEY_RE = /^((?:[^#:\s]|:[^ ])(?:[^#:]|:[^ ]|(?<=[^ ])#)*):(?:[ ]+(.*))?$/
      # A quoted mapping key followed by ":". A whole-line quoted scalar
      # cannot match because its closing quote is at the end of the line.
      QUOTED_KEY_RE = /^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'):(?:[ ]+(.*))?$/
      MAX_NESTING_DEPTH = 1_000

      STRING_UNESCAPES = {
        "\\\\" => "\\",
        "\\\"" => "\"",
        "\\0" => "\0",
        "\\a" => "\a",
        "\\b" => "\b",
        "\\t" => "\t",
        "\\n" => "\n",
        "\\v" => "\v",
        "\\f" => "\f",
        "\\r" => "\r",
        "\\e" => "\e",
      }.freeze

      def initialize(source)
        @lines = source.split(/\r?\n/)
        @anchors = {}
        @depth = 0
        strip_document_prefix
      end

      def parse
        return nil if @lines.empty?

        root = nil
        while @lines.any?
          before = @lines.size
          node = parse_node(-1)
          @lines.shift if @lines.size == before && @lines.any?

          if root.is_a?(Mapping) && node.is_a?(Mapping)
            root.pairs.concat(node.pairs)
          elsif root.nil?
            root = node
          end
        end
        root
      end

      private

      def strip_document_prefix
        return if @lines.empty?
        return unless @lines[0]&.start_with?("---")

        if @lines[0].strip == "---"
          @lines.shift
        else
          @lines[0] = @lines[0].sub(/^---\s*/, "")
        end
      end

      def parse_node(base_indent)
        @depth += 1
        raise_max_nesting! if @depth > MAX_NESTING_DEPTH

        skip_blank_and_comments
        return nil if @lines.empty?

        line = @lines[0]
        stripped = line.lstrip
        indent = line.size - stripped.size
        return nil if indent < base_indent

        return parse_alias_ref if stripped.start_with?("*")

        anchor = consume_anchor

        if anchor
          line = @lines[0]
          stripped = line.lstrip
        end

        if stripped.start_with?("- ") || stripped == "-"
          parse_sequence(indent, anchor)
        elsif QUOTED_KEY_RE.match?(stripped)
          # A mapping whose key is quoted, e.g. '"have: colon": value'.
          parse_mapping(indent, anchor)
        elsif (stripped.start_with?("\"") && stripped.end_with?("\"")) ||
              (stripped.start_with?("'") && stripped.end_with?("'"))
          # A whole-line quoted scalar, e.g. '"system: foo: bar"'. It may
          # contain ": ", so it must be checked before MAPPING_KEY_RE.
          parse_plain_scalar(indent, anchor)
        elsif stripped =~ MAPPING_KEY_RE && !stripped.start_with?("!ruby/object:")
          parse_mapping(indent, anchor)
        elsif stripped.start_with?("!ruby/object:")
          parse_tagged_node(indent, anchor)
        elsif stripped.start_with?("|")
          modifier = stripped[1..].to_s.strip
          @lines.shift
          register_anchor(anchor, Scalar.new(value: parse_block_scalar(indent, modifier)))
        else
          parse_plain_scalar(indent, anchor)
        end
      ensure
        @depth -= 1
      end

      def parse_sequence(indent, anchor)
        items = []
        while @lines.any?
          line = @lines[0]
          stripped = line.lstrip
          break unless line.size - stripped.size == indent &&
                       (stripped.start_with?("- ") || stripped == "-")
          content = @lines.shift.lstrip[1..].strip
          item_anchor, content = extract_item_anchor(content)
          item = parse_sequence_item(content, indent)
          items << register_anchor(item_anchor, item)
        end
        register_anchor(anchor, Sequence.new(items: items))
      end

      def parse_sequence_item(content, indent)
        if content.start_with?("*")
          parse_inline_alias(content)
        elsif content.empty?
          @lines.any? && current_indent > indent ? parse_node(indent) : nil
        elsif content.start_with?("!ruby/object:")
          parse_tagged_content(content.strip, indent)
        elsif content.start_with?("!binary ")
          parse_binary_value(content, indent)
        elsif content.start_with?("-")
          @lines.unshift("#{" " * (indent + 2)}#{content}")
          parse_node(indent)
        elsif QUOTED_KEY_RE.match?(content) ||
              (content =~ MAPPING_KEY_RE && !content.start_with?("!ruby/object:"))
          @lines.unshift("#{" " * (indent + 2)}#{content}")
          parse_node(indent)
        elsif content.start_with?("|")
          Scalar.new(value: parse_block_scalar(indent, content[1..].to_s.strip))
        else
          parse_inline_scalar(content, indent)
        end
      end

      def parse_mapping(indent, anchor)
        pairs = []
        while @lines.any?
          line = @lines[0]
          stripped = line.lstrip
          break unless line.size - stripped.size == indent

          if (match = QUOTED_KEY_RE.match(stripped))
            key = coerce(match[1])
          elsif (match = MAPPING_KEY_RE.match(stripped)) && !stripped.start_with?("!ruby/object:")
            key = match[1].strip
            key = decode_binary_tag(key) if key.start_with?("!binary ")
          else
            break
          end
          @lines.shift
          val = strip_comment(match[2].to_s.strip)

          val_anchor, val = consume_value_anchor(val)
          value = parse_mapping_value(val, indent)
          value = register_anchor(val_anchor, value) if val_anchor

          pairs << [Scalar.new(value: key), value]
        end
        register_anchor(anchor, Mapping.new(pairs: pairs))
      end

      def parse_mapping_value(val, indent)
        if val.start_with?("*")
          parse_inline_alias(val)
        elsif val.start_with?("!ruby/object:")
          parse_tagged_content(val.strip, indent)
        elsif val.start_with?("!binary ")
          parse_binary_value(val, indent)
        elsif val.empty?
          next_stripped = nil
          next_indent = nil
          if @lines.any?
            next_stripped = @lines[0].lstrip
            next_indent = @lines[0].size - next_stripped.size
          end
          if next_stripped &&
             (next_stripped.start_with?("- ") || next_stripped == "-") &&
             next_indent == indent
            parse_node(indent)
          else
            parse_node(indent + 1)
          end
        elsif val == "[]"
          Sequence.new
        elsif val == "{}"
          Mapping.new
        elsif val.start_with?("|")
          Scalar.new(value: parse_block_scalar(indent, val[1..].to_s.strip))
        else
          parse_inline_scalar(val, indent)
        end
      end

      def parse_tagged_node(indent, anchor)
        tag = @lines.shift.strip
        nested = parse_node(indent)
        apply_tag(nested, tag, anchor)
      end

      def parse_tagged_content(tag, indent)
        nested = parse_node(indent)
        apply_tag(nested, tag, nil)
      end

      def apply_tag(node, tag, anchor)
        if node.is_a?(Mapping)
          node.tag = tag
          node.anchor = anchor
          node
        else
          Mapping.new(pairs: [[Scalar.new(value: "value"), node]], tag: tag, anchor: anchor)
        end
      end

      def parse_block_scalar(base_indent, modifier)
        parts = []
        block_indent = nil

        while @lines.any?
          line = @lines[0]
          if line.strip.empty?
            parts << "\n"
            @lines.shift
          else
            line_indent = line.size - line.lstrip.size
            break if line_indent <= base_indent
            block_indent ||= line_indent
            parts << @lines.shift[block_indent..].to_s << "\n"
          end
        end

        res = parts.join
        res.chomp! if modifier == "-" && res.end_with?("\n")
        res
      end

      def parse_plain_scalar(indent, anchor)
        result = coerce(@lines.shift.strip)
        return register_anchor(anchor, result) if result.is_a?(Mapping) || result.is_a?(Sequence)

        while result.is_a?(String) && @lines.any? &&
              !@lines[0].strip.empty? && current_indent > indent
          result << " " << @lines.shift.strip
        end
        register_anchor(anchor, Scalar.new(value: result))
      end

      def parse_inline_scalar(val, indent)
        result = coerce(val)
        return result if result.is_a?(Mapping) || result.is_a?(Sequence)

        while result.is_a?(String) && @lines.any? &&
              !@lines[0].strip.empty? && current_indent > indent
          result << " " << @lines.shift.strip
        end
        Scalar.new(value: result)
      end

      def coerce(val, depth = 0)
        raise_max_nesting! if depth > MAX_NESTING_DEPTH

        val = val.sub(/^! /, "") if val.start_with?("! ")

        if val =~ /^"(.*)"$/
          $1.gsub(/\\(?:["\\0abtnvfre]|x\h{2})/) do |m|
            STRING_UNESCAPES[m] || m[2..].to_i(16).chr(Encoding::UTF_8)
          end
        elsif val =~ /^'(.*)'$/
          $1.gsub(/''/, "'")
        elsif val == "true"
          true
        elsif val == "false"
          false
        elsif ["~", "null"].include?(val)
          nil
        elsif val == "{}"
          Mapping.new
        elsif val =~ /^\[(.*)\]$/
          inner = $1.strip
          return Sequence.new if inner.empty?
          items = inner.split(/\s*,\s*/).reject(&:empty?).map {|e| Scalar.new(value: coerce(e, depth + 1)) }
          Sequence.new(items: items)
        elsif /\A\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.match?(val)
          begin
            Time.new(val)
          rescue ArgumentError
            # date-only format like "2024-06-15" is not supported by Time.new
            if /\A(\d{4})-(\d{2})-(\d{2})\z/.match(val)
              Time.utc($1.to_i, $2.to_i, $3.to_i)
            else
              val
            end
          end
        elsif /^-?\d+$/.match?(val)
          val.to_i
        else
          val
        end
      end

      def decode_binary_tag(str)
        content = str.sub(/\A!binary\s+/, "")
        content = $1 if content =~ /\A"(.*)"\z/ || content =~ /\A'(.*)'\z/
        content.unpack1("m")
      end

      def parse_binary_value(val, indent)
        rest = val.sub(/\A!binary\s+/, "")
        if rest.start_with?("|")
          content = parse_block_scalar(indent, rest[1..].to_s.strip)
          Scalar.new(value: content.unpack1("m"))
        else
          Scalar.new(value: decode_binary_tag(val))
        end
      end

      def parse_alias_ref
        AliasRef.new(name: @lines.shift.lstrip[1..].strip)
      end

      def parse_inline_alias(content)
        AliasRef.new(name: content[1..].strip)
      end

      def current_indent
        line = @lines[0]
        line.size - line.lstrip.size
      end

      def consume_anchor
        line = @lines[0]
        stripped = line.lstrip
        return nil unless stripped.start_with?("&") && stripped =~ /^&(\S+)\s+/

        anchor = $1
        @lines[0] = line.sub(/&#{Regexp.escape(anchor)}\s+/, "")
        anchor
      end

      def extract_item_anchor(content)
        return [nil, content] unless content =~ /^&(\S+)/

        anchor = $1
        [anchor, content.sub(/^&#{Regexp.escape(anchor)}\s*/, "")]
      end

      def consume_value_anchor(val)
        return [nil, val] unless val =~ /^&(\S+)\s+/

        anchor = $1
        [anchor, val.sub(/^&#{Regexp.escape(anchor)}\s+/, "")]
      end

      def register_anchor(name, node)
        if name
          @anchors[name] = node
          node.anchor = name if node.respond_to?(:anchor=)
        end
        node
      end

      def raise_max_nesting!
        message = "exceeded maximum nesting depth (#{MAX_NESTING_DEPTH})"
        if defined?(Psych::VERSION)
          raise Psych::SyntaxError.new(nil, 0, 0, 0, message, nil)
        else
          raise Psych::SyntaxError, message
        end
      end

      def skip_blank_and_comments
        while @lines.any?
          line = @lines[0]
          stripped = line.lstrip
          break unless stripped.empty? || stripped.start_with?("#")
          @lines.shift
        end
      end

      def strip_comment(val)
        return val unless val.include?("#")
        return "" if val.lstrip.start_with?("#")

        in_single = false
        in_double = false
        escape = false

        val.each_char.with_index do |ch, i|
          if escape
            escape = false
            next
          end

          if in_single
            in_single = false if ch == "'"
          elsif in_double
            if ch == "\\"
              escape = true
            elsif ch == '"'
              in_double = false
            end
          else
            case ch
            when "'" then in_single = true
            when '"' then in_double = true
            when "#"
              # A "#" starts a comment only when preceded by whitespace.
              return val[0...i].rstrip if [" ", "\t"].include?(val[i - 1])
            end
          end
        end

        val
      end
    end

    class Builder
      VALID_OPS = %w[= != > < >= <= ~>].freeze
      ARRAY_FIELDS = %w[files test_files executables extra_rdoc_files].freeze
      MAX_ALIAS_RESOLUTIONS = 1_000

      def initialize(permitted_classes: [], permitted_symbols: [], aliases: true)
        @permitted_classes = permitted_classes.map {|c| "!ruby/object:#{c}" }
        @permitted_symbols = permitted_symbols
        @aliases = aliases
        @anchor_values = {}
        @alias_count = 0
      end

      def build(node)
        return nil if node.nil?

        result = build_node(node)

        if result.is_a?(Hash) && result[:tag] == "!ruby/object:Gem::Specification"
          build_specification(result)
        else
          result
        end
      end

      private

      def build_node(node)
        case node
        when nil then nil
        when AliasRef then resolve_alias(node)
        when Scalar then store_anchor(node.anchor, node.value)
        when Mapping then build_mapping(node)
        when Sequence then store_anchor(node.anchor, node.items.map {|item| build_node(item) })
        else node # already a Ruby object
        end
      end

      def resolve_alias(node)
        raise Psych::AliasesNotEnabled unless @aliases
        @alias_count += 1
        if @alias_count > MAX_ALIAS_RESOLUTIONS
          raise Psych::BadAlias, "exceeded maximum alias resolutions (#{MAX_ALIAS_RESOLUTIONS})"
        end
        unless @anchor_values.key?(node.name)
          klass = defined?(Psych::AnchorNotDefined) ? Psych::AnchorNotDefined : Psych::BadAlias
          raise klass, "An alias referenced an unknown anchor: #{node.name}"
        end
        @anchor_values.fetch(node.name)
      end

      def store_anchor(name, value)
        @anchor_values[name] = value if name
        value
      end

      def build_mapping(node)
        validate_tag!(node.tag) if node.tag

        result = case node.tag
                 when "!ruby/object:Gem::Version"
                   build_version(node)
                 when "!ruby/object:Gem::Platform"
                   build_platform(node)
                 when "!ruby/object:Gem::Requirement", "!ruby/object:Gem::Version::Requirement"
                   build_requirement(node)
                 when "!ruby/object:Gem::Dependency"
                   build_dependency(node)
                 when nil
                   build_hash(node)
                 when "!ruby/object:Gem::Specification"
                   hash = build_hash(node)
                   hash[:tag] = node.tag
                   hash
                 else
                   raise ArgumentError, "undefined class/module #{node.tag.sub("!ruby/object:", "")}"
        end

        store_anchor(node.anchor, result)
      end

      def build_hash(node)
        result = {}
        node.pairs.each do |key_node, value_node|
          key = key_node.is_a?(Scalar) ? key_node.value.to_s : build_node(key_node).to_s
          value = build_node(value_node)

          if ARRAY_FIELDS.include?(key)
            value = normalize_array_field(value)
          end

          result[key] = value
        end
        result
      end

      def build_version(node)
        hash = pairs_to_hash(node)
        Gem::Version.new((hash["version"] || hash["value"]).to_s)
      end

      PLATFORM_FIELDS = %w[cpu os version].freeze
      PLATFORM_ALLOWED_IVARS = %w[cpu os version value].freeze

      def build_platform(node)
        hash = pairs_to_hash(node)
        if (hash.keys & PLATFORM_FIELDS).any?
          Gem::Platform.new([hash["cpu"], hash["os"], hash["version"]])
        elsif hash["value"].is_a?(Array)
          # Malformed platform (e.g. sequence instead of mapping).
          # Return the raw value so yaml_initialize handles it like Psych does.
          hash["value"]
        else
          plat = Gem::Platform.allocate
          hash.each do |k, v|
            plat.instance_variable_set(:"@#{k}", v) if PLATFORM_ALLOWED_IVARS.include?(k)
          end
          plat
        end
      end

      def build_requirement(node)
        r = Gem::Requirement.allocate
        hash = pairs_to_hash(node)
        reqs = hash["requirements"] || hash["value"]

        if reqs.is_a?(Array) && !reqs.empty?
          safe_reqs = []
          reqs.each do |item|
            if item.is_a?(Array) && item.size == 2
              op = item[0].to_s
              ver = item[1]
              if VALID_OPS.include?(op)
                version_obj = ver.is_a?(Gem::Version) ? ver : Gem::Version.new(ver.to_s)
                safe_reqs << [op, version_obj]
              end
            elsif item.is_a?(String)
              parsed = Gem::Requirement.parse(item)
              safe_reqs << parsed
            end
          rescue Gem::Requirement::BadRequirementError, Gem::Version::BadVersionError
            # Skip malformed items silently
          end
          reqs = safe_reqs unless safe_reqs.empty?
        end

        r.instance_variable_set(:@requirements, reqs)
        r
      end

      def build_dependency(node)
        hash = pairs_to_hash(node)
        d = Gem::Dependency.allocate
        d.instance_variable_set(:@name, hash["name"])

        d.instance_variable_set(:@requirement, hash["requirement"] || hash["version_requirements"])

        raw_type = hash["type"]
        if raw_type
          name = raw_type.to_s.sub(/^:/, "")
          validate_symbol!(name)
          type = name.to_sym
        else
          type = :runtime
        end
        d.instance_variable_set(:@type, type)

        d.instance_variable_set(:@prerelease, ["true", true].include?(hash["prerelease"]))
        d.instance_variable_set(:@version_requirements, d.instance_variable_get(:@requirement))
        d
      end

      def build_specification(hash)
        spec = Gem::Specification.allocate

        normalize_specification_version!(hash)
        normalize_array_fields!(hash)

        spec.yaml_initialize("!ruby/object:Gem::Specification", hash)
        spec
      end

      def pairs_to_hash(node)
        result = {}
        node.pairs.each do |key_node, value_node|
          key = key_node.is_a?(Scalar) ? key_node.value.to_s : build_node(key_node).to_s
          result[key] = build_node(value_node)
        end
        result
      end

      def validate_tag!(tag)
        return if @permitted_classes.include?(tag)
        raise_disallowed_class!(tag)
      end

      def raise_disallowed_class!(tag)
        if defined?(Psych::VERSION)
          raise Psych::DisallowedClass.new("load", tag)
        else
          raise Psych::DisallowedClass, "Tried to load unspecified class: #{tag}"
        end
      end

      def validate_symbol!(name)
        return if @permitted_symbols.empty? || @permitted_symbols.include?(name)

        label = ":#{name}"
        if defined?(Psych::VERSION)
          raise Psych::DisallowedClass.new("load", label)
        else
          raise Psych::DisallowedClass, "Tried to load unspecified class: #{label}"
        end
      end

      def normalize_specification_version!(hash)
        val = hash["specification_version"]
        return unless val && !val.is_a?(Integer)
        hash["specification_version"] = val.to_i if val.is_a?(String) && /\A\d+\z/.match?(val)
      end

      def normalize_array_fields!(hash)
        ARRAY_FIELDS.each do |field|
          hash[field] = normalize_array_field(hash[field]) if hash[field]
        end
      end

      def normalize_array_field(value)
        if value.is_a?(Hash)
          value.values.flatten.compact
        elsif !value.is_a?(Array) && value
          [value].flatten.compact
        else
          value
        end
      end
    end

    class Emitter
      STRING_ESCAPES = {
        "\\" => "\\\\",
        "\"" => "\\\"",
        "\0" => "\\0",
        "\a" => "\\a",
        "\b" => "\\b",
        "\t" => "\\t",
        "\n" => "\\n",
        "\v" => "\\v",
        "\f" => "\\f",
        "\r" => "\\r",
        "\e" => "\\e",
      }.freeze

      def emit(obj)
        "---#{emit_node(obj, 0)}"
      end

      private

      def emit_node(obj, indent, quote: false)
        case obj
        when Gem::Specification then emit_specification(obj, indent)
        when Gem::Version       then emit_version(obj, indent)
        when Gem::Platform      then emit_platform(obj, indent)
        when Gem::Requirement   then emit_requirement(obj, indent)
        when Gem::Dependency    then emit_dependency(obj, indent)
        when Hash               then emit_hash(obj, indent)
        when Array              then emit_array(obj, indent)
        when Time               then emit_time(obj)
        when String             then emit_string(obj, indent, quote: quote)
        when NilClass
          "\n"
        when Numeric, Symbol, TrueClass, FalseClass
          " #{obj.inspect}\n"
        else
          " #{quote_string(obj.to_s)}\n"
        end
      end

      def emit_specification(spec, indent)
        parts = [" !ruby/object:Gem::Specification\n"]
        parts << "#{pad(indent)}name:#{emit_node(spec.name, indent + 2)}"
        parts << "#{pad(indent)}version:#{emit_node(spec.version, indent + 2)}"
        parts << "#{pad(indent)}platform: #{spec.platform}\n"
        if spec.platform.to_s != spec.original_platform.to_s
          parts << "#{pad(indent)}original_platform: #{spec.original_platform}\n"
        end

        attributes = Gem::Specification.attribute_names.map(&:to_s).sort - %w[name version platform]
        attributes.each do |name|
          val = spec.instance_variable_get("@#{name}")
          next if val.nil?
          parts << "#{pad(indent)}#{name}:#{emit_node(val, indent + 2)}"
        end

        res = parts.join
        res << "\n" unless res.end_with?("\n")
        res
      end

      def emit_version(ver, indent)
        " !ruby/object:Gem::Version\n" \
          "#{pad(indent)}version: #{emit_node(ver.version.to_s, indent + 2).lstrip}"
      end

      def emit_platform(plat, indent)
        " !ruby/object:Gem::Platform\n" \
          "#{pad(indent)}cpu:#{emit_node(plat.cpu, indent + 2)}" \
          "#{pad(indent)}os:#{emit_node(plat.os, indent + 2)}" \
          "#{pad(indent)}version:#{emit_node(plat.version, indent + 2)}"
      end

      def emit_requirement(req, indent)
        " !ruby/object:Gem::Requirement\n" \
          "#{pad(indent)}requirements:#{emit_node(req.requirements, indent + 2)}"
      end

      def emit_dependency(dep, indent)
        [
          " !ruby/object:Gem::Dependency\n",
          "#{pad(indent)}name: #{emit_node(dep.name, indent + 2).lstrip}",
          "#{pad(indent)}requirement:#{emit_node(dep.requirement, indent + 2)}",
          "#{pad(indent)}type: #{emit_node(dep.type, indent + 2).lstrip}",
          "#{pad(indent)}prerelease: #{emit_node(dep.prerelease?, indent + 2).lstrip}",
          "#{pad(indent)}version_requirements:#{emit_node(dep.requirement, indent + 2)}",
        ].join
      end

      def emit_hash(hash, indent)
        if hash.empty?
          " {}\n"
        else
          parts = ["\n"]
          hash.each do |k, v|
            is_symbol = k.is_a?(Symbol) || (k.is_a?(String) && k.start_with?(":"))
            key_str = k.is_a?(Symbol) ? k.inspect : k.to_s
            key_str = quote_string(key_str) if !is_symbol && needs_quoting?(key_str)
            parts << "#{pad(indent)}#{key_str}:#{emit_node(v, indent + 2, quote: is_symbol)}"
          end
          parts.join
        end
      end

      def emit_array(arr, indent)
        if arr.empty?
          " []\n"
        else
          parts = ["\n"]
          arr.each do |v|
            parts << "#{pad(indent)}-#{emit_node(v, indent + 2)}"
          end
          parts.join
        end
      end

      def emit_time(time)
        " #{time.utc.strftime("%Y-%m-%d %H:%M:%S.%N Z")}\n"
      end

      def emit_string(str, indent, quote: false)
        # A CR cannot be represented in a block scalar because parsing
        # normalizes CRLF line breaks to LF, so quote and escape instead.
        if str.include?("\n") && !str.include?("\r")
          emit_block_scalar(str, indent)
        elsif needs_quoting?(str, quote)
          " #{quote_string(str)}\n"
        else
          " #{str}\n"
        end
      end

      def emit_block_scalar(str, indent)
        parts = [str.end_with?("\n") ? " |\n" : " |-\n"]
        str.each_line do |line|
          parts << "#{pad(indent + 2)}#{line}"
        end
        res = parts.join
        res << "\n" unless res.end_with?("\n")
        res
      end

      def needs_quoting?(str, quote = false)
        quote || str.empty? || str != str.strip || str =~ /[[:cntrl:]]/ ||
          str =~ /^[!*&:@%$"'|`]/ || str =~ /^-?\d+(\.\d+)?$/ || str =~ /^[<>=-]/ ||
          str == "true" || str == "false" || str == "nil" || str == "null" || str == "~" ||
          str.include?(":") || str.include?("#") || str.include?("[") || str.include?("]") ||
          str.include?("{") || str.include?("}") || str.include?(",")
      end

      def quote_string(str)
        %("#{str.gsub(/[\\"]|[[:cntrl:]]/) {|c| STRING_ESCAPES[c] || format("\\x%02X", c.ord) }}")
      end

      def pad(indent)
        " " * indent
      end
    end

    module_function

    def dump(obj)
      Emitter.new.emit(obj)
    end

    def load(str, permitted_classes: [], permitted_symbols: [], aliases: true)
      raise TypeError, "no implicit conversion of nil into String" if str.nil?
      return nil if str.empty?

      ast = Parser.new(str).parse
      return nil if ast.nil?

      Builder.new(
        permitted_classes: permitted_classes,
        permitted_symbols: permitted_symbols,
        aliases: aliases
      ).build(ast)
    end
  end
end
