# frozen_string_literal: true

require_relative '../../../puppet/concurrent/thread_local_singleton'

module Puppet::Pops
module Types
# String
# ------
# Creates a string representation of a type.
#
# @api public
#
class TypeFormatter
  extend Puppet::Concurrent::ThreadLocalSingleton

  # Produces a String representation of the given type.
  # @param t [PAnyType] the type to produce a string form
  # @return [String] the type in string form
  #
  # @api public
  #
  def self.string(t)
    singleton.string(t)
  end

  def initialize
    @string_visitor = Visitor.new(nil, 'string', 0, 0)
  end

  def expanded
    tf = clone
    tf.instance_variable_set(:@expanded, true)
    tf
  end

  def indented(indent = 0, indent_width = 2)
    tf = clone
    tf.instance_variable_set(:@indent, indent)
    tf.instance_variable_set(:@indent_width, indent_width)
    tf
  end

  def ruby(ref_ctor)
    tf = clone
    tf.instance_variable_set(:@ruby, true)
    tf.instance_variable_set(:@ref_ctor, ref_ctor)
    tf
  end

  # Produces a string representing the type
  # @api public
  #
  def string(t)
    @bld = ''.dup
    append_string(t)
    @bld
  end

  # Produces an string containing newline characters and indentation that represents the given
  # type or literal _t_.
  #
  # @param t [Object] the type or literal to produce a string for
  # @param indent [Integer] the current indentation level
  # @param indent_width [Integer] the number of spaces to use for one indentation
  #
  # @api public
  def indented_string(t, indent = 0, indent_width = 2)
    @bld = ''.dup
    append_indented_string(t, indent, indent_width)
    @bld
  end

  # @api private
  def append_indented_string(t, indent = 0, indent_width = 2, skip_initial_indent = false)
    save_indent = @indent
    save_indent_width = @indent_width
    @indent = indent
    @indent_width = indent_width
    begin
      (@indent * @indent_width).times { @bld << ' ' } unless skip_initial_indent
      append_string(t)
      @bld << "\n"
    ensure
      @indent = save_indent
      @indent_width = save_indent_width
    end
  end

  # @api private
  def ruby_string(ref_ctor, indent, t)
    @ruby = true
    @ref_ctor = ref_ctor
    begin
      indented_string(t, indent)
    ensure
      @ruby = nil
      @ref_ctor = nil
    end
  end

  def append_default
    @bld << 'default'
  end

  def append_string(t)
    if @ruby && t.is_a?(PAnyType)
      @ruby = false
      begin
        @bld << @ref_ctor << '('
        @string_visitor.visit_this_0(self, TypeFormatter.new.string(t))
        @bld << ')'
      ensure
        @ruby = true
      end
    else
      @string_visitor.visit_this_0(self, t)
    end
  end

  # Produces a string representing the type where type aliases have been expanded
  # @api public
  #
  def alias_expanded_string(t)
    @expanded = true
    begin
      string(t)
    ensure
      @expanded = false
    end
  end

  # Produces a debug string representing the type (possibly with more information that the regular string format)
  # @api public
  #
  def debug_string(t)
    @debug = true
    begin
      string(t)
    ensure
      @debug = false
    end
  end

  # @api private
  def string_PAnyType(_); @bld << 'Any'; end

  # @api private
  def string_PUndefType(_); @bld << 'Undef'; end

  # @api private
  def string_PDefaultType(_); @bld << 'Default'; end

  # @api private
  def string_PBooleanType(t)
    append_array('Boolean', t.value.nil?) { append_string(t.value) }
  end

  # @api private
  def string_PScalarType(_); @bld << 'Scalar'; end

  # @api private
  def string_PScalarDataType(_); @bld << 'ScalarData'; end

  # @api private
  def string_PNumericType(_); @bld << 'Numeric'; end

  # @api private
  def string_PBinaryType(_); @bld << 'Binary'; end

  # @api private
  def string_PIntegerType(t)
    append_array('Integer', t.unbounded?) { append_elements(range_array_part(t)) }
  end

  # @api private
  def string_PTypeType(t)
    append_array('Type', t.type.nil?) { append_string(t.type) }
  end

  # @api private
  def string_PInitType(t)
    append_array('Init', t.type.nil?) { append_strings([t.type, *t.init_args]) }
  end

  # @api private
  def string_PIterableType(t)
    append_array('Iterable', t.element_type.nil?) { append_string(t.element_type) }
  end

  # @api private
  def string_PIteratorType(t)
    append_array('Iterator', t.element_type.nil?) { append_string(t.element_type) }
  end

  # @api private
  def string_PFloatType(t)
    append_array('Float', t.unbounded?) { append_elements(range_array_part(t)) }
  end

  # @api private
  def string_PRegexpType(t)
    append_array('Regexp', t.pattern.nil?) { append_string(t.regexp) }
  end

  # @api private
  def string_PStringType(t)
    range = range_array_part(t.size_type)
    append_array('String', range.empty? && !(@debug && !t.value.nil?)) do
      if @debug
        append_elements(range, !t.value.nil?)
        append_string(t.value) unless t.value.nil?
      else
        append_elements(range)
      end
    end
  end

  # @api private
  def string_PEnumType(t)
    append_array('Enum', t.values.empty?) do
      append_strings(t.values)
      if t.case_insensitive?
        @bld << COMMA_SEP
        append_string(true)
      end
    end
  end

  # @api private
  def string_PVariantType(t)
    append_array('Variant', t.types.empty?) { append_strings(t.types) }
  end

  # @api private
  def string_PSemVerType(t)
    append_array('SemVer', t.ranges.empty?) { append_strings(t.ranges) }
  end

  # @api private
  def string_PSemVerRangeType(t)
    @bld << 'SemVerRange'
  end

  # @api private
  def string_PTimestampType(t)
    min = t.from
    max = t.to
    append_array('Timestamp', min.nil? && max.nil?) do
      min.nil? ? append_default : append_string(min)
      unless max.nil? || max == min
        @bld << COMMA_SEP
        append_string(max)
      end
    end
  end

  # @api private
  def string_PTimespanType(t)
    min = t.from
    max = t.to
    append_array('Timespan', min.nil? && max.nil?) do
      min.nil? ? append_default : append_string(min)
      unless max.nil? || max == min
        @bld << COMMA_SEP
        append_string(max)
      end
    end
  end

  # @api private
  def string_PTupleType(t)
    append_array('Tuple', t.types.empty?) do
      append_strings(t.types, true)
      append_elements(range_array_part(t.size_type), true)
      chomp_list
    end
  end

  # @api private
  def string_PCallableType(t)
    if t.return_type.nil?
      append_array('Callable', t.param_types.nil?) { append_callable_params(t) }
    elsif t.param_types.nil?
      append_array('Callable', false) { append_strings([[], t.return_type], false) }
    else
      append_array('Callable', false) do
        append_array('', false) { append_callable_params(t) }
        @bld << COMMA_SEP
        append_string(t.return_type)
      end
    end
  end

  def append_callable_params(t)
    # translate to string, and skip Unit types
    append_strings(t.param_types.types.reject { |t2| t2.instance_of?(PUnitType) }, true)

    if t.param_types.types.empty?
      append_strings([0, 0], true)
    else
      append_elements(range_array_part(t.param_types.size_type), true)
    end

    # Add block T last (after min, max) if present)
    #
    append_strings([t.block_type], true) unless t.block_type.nil?
    chomp_list
  end

  # @api private
  def string_PStructType(t)
    append_array('Struct', t.elements.empty?) { append_hash(t.elements.to_h { |e| struct_element_pair(e) }) }
  end

  # @api private
  def struct_element_pair(t)
    k = t.key_type
    value_optional = t.value_type.assignable?(PUndefType::DEFAULT)
    if k.is_a?(POptionalType)
      # Output as literal String
      k = t.name if value_optional
    else
      k = value_optional ? PNotUndefType.new(k) : t.name
    end
    [k, t.value_type]
  end

  # @api private
  def string_PPatternType(t)
    append_array('Pattern', t.patterns.empty?) { append_strings(t.patterns.map(&:regexp)) }
  end

  # @api private
  def string_PCollectionType(t)
    range = range_array_part(t.size_type)
    append_array('Collection', range.empty?) { append_elements(range) }
  end

  def string_Object(t)
    type = TypeCalculator.infer(t)
    if type.is_a?(PObjectTypeExtension)
      type = type.base_type
    end
    if type.is_a?(PObjectType)
      init_hash = type.extract_init_hash(t)
      @bld << type.name << '('
      if @indent
        append_indented_string(init_hash, @indent, @indent_width, true)
        @bld.chomp!
      else
        append_string(init_hash)
      end
      @bld << ')'
    else
      @bld << 'Instance of '
      append_string(type)
    end
  end

  def string_PuppetObject(t)
    @bld << t._pcore_type.name << '('
    if @indent
      append_indented_string(t._pcore_init_hash, @indent, @indent_width, true)
      @bld.chomp!
    else
      append_string(t._pcore_init_hash)
    end
    @bld << ')'
  end

  # @api private
  def string_PURIType(t)
    append_array('URI', t.parameters.nil?) { append_string(t._pcore_init_hash['parameters']) }
  end

  def string_URI(t)
    @bld << 'URI('
    if @indent
      append_indented_string(t.to_s, @indent, @indent_width, true)
      @bld.chomp!
    else
      append_string(t.to_s)
    end
    @bld << ')'
  end

  # @api private
  def string_PUnitType(_)
    @bld << 'Unit'
  end

  # @api private
  def string_PRuntimeType(t)
    append_array('Runtime', t.runtime.nil? && t.name_or_pattern.nil?) { append_strings([t.runtime, t.name_or_pattern]) }
  end

  # @api private
  def string_PArrayType(t)
    if t.has_empty_range?
      append_array('Array') { append_strings([0, 0]) }
    else
      append_array('Array', t == PArrayType::DEFAULT) do
        append_strings([t.element_type], true)
        append_elements(range_array_part(t.size_type), true)
        chomp_list
      end
    end
  end

  # @api private
  def string_PHashType(t)
    if t.has_empty_range?
      append_array('Hash') { append_strings([0, 0]) }
    else
      append_array('Hash', t == PHashType::DEFAULT) do
        append_strings([t.key_type, t.value_type], true)
        append_elements(range_array_part(t.size_type), true)
        chomp_list
      end
    end
  end

  # @api private
  def string_PCatalogEntryType(_)
    @bld << 'CatalogEntry'
  end

  # @api private
  def string_PClassType(t)
    append_array('Class', t.class_name.nil?) { append_elements([t.class_name]) }
  end

  # @api private
  def string_PResourceType(t)
    if t.type_name
      append_array(capitalize_segments(t.type_name), t.title.nil?) { append_string(t.title) }
    else
      @bld << 'Resource'
    end
  end

  # @api private
  def string_PNotUndefType(t)
    contained_type = t.type
    append_array('NotUndef', contained_type.nil? || contained_type.instance_of?(PAnyType)) do
      if contained_type.is_a?(PStringType) && !contained_type.value.nil?
        append_string(contained_type.value)
      else
        append_string(contained_type)
      end
    end
  end

  # @api private
  def string_PAnnotatedMember(m)
    hash = m._pcore_init_hash
    if hash.size == 1
      string(m.type)
    else
      string(hash)
    end
  end

  # Used when printing names of well known keys in an Object type. Placed in a separate
  # method to allow override.
  # @api private
  def symbolic_key(key)
    @ruby ? "'#{key}'" : key
  end

  # @api private
  def string_PTypeSetType(t)
    append_array('TypeSet') do
      append_hash(t._pcore_init_hash.each, proc { |k| @bld << symbolic_key(k) }) do |k, v|
        case k
        when KEY_TYPES
          old_ts = @type_set
          @type_set = t
          begin
            append_hash(v, proc { |tk| @bld << symbolic_key(tk) }) do |_tk, tv|
              if tv.is_a?(Hash)
                append_object_hash(tv)
              else
                append_string(tv)
              end
            end
          rescue
            @type_set = old_ts
          end
        when KEY_REFERENCES
          append_hash(v, proc { |tk| @bld << symbolic_key(tk) })
        else
          append_string(v)
        end
      end
    end
  end

  # @api private
  def string_PObjectType(t)
    if @expanded
      append_object_hash(t._pcore_init_hash(@type_set.nil? || !@type_set.defines_type?(t)))
    else
      @bld << (@type_set ? @type_set.name_for(t, t.label) : t.label)
    end
  end

  def string_PObjectTypeExtension(t)
    append_array(@type_set ? @type_set.name_for(t, t.name) : t.name, false) do
      ips = t.init_parameters
      if ips.is_a?(Array)
        append_strings(ips)
      else
        append_string(ips)
      end
    end
  end

  # @api private
  def string_PSensitiveType(t)
    append_array('Sensitive', PAnyType::DEFAULT == t.type) { append_string(t.type) }
  end

  # @api private
  def string_POptionalType(t)
    optional_type = t.optional_type
    append_array('Optional', optional_type.nil?) do
      if optional_type.is_a?(PStringType) && !optional_type.value.nil?
        append_string(optional_type.value)
      else
        append_string(optional_type)
      end
    end
  end

  # @api private
  def string_PTypeAliasType(t)
    expand = @expanded
    if expand && t.self_recursion?
      @guard ||= RecursionGuard.new
      @guard.with_this(t) { |state| format_type_alias_type(t, (state & RecursionGuard::SELF_RECURSION_IN_THIS) == 0) }
    else
      format_type_alias_type(t, expand)
    end
  end

  # @api private
  def format_type_alias_type(t, expand)
    if @type_set.nil?
      @bld << t.name
      if expand && !Loader::StaticLoader::BUILTIN_ALIASES.include?(t.name)
        @bld << ' = '
        append_string(t.resolved_type)
      end
    elsif expand && @type_set.defines_type?(t)
      append_string(t.resolved_type)
    else
      @bld << @type_set.name_for(t, t.name)
    end
  end

  # @api private
  def string_PTypeReferenceType(t)
    append_array('TypeReference') { append_string(t.type_string) }
  end

  # @api private
  def string_Array(t)
    append_array('') do
      if @indent && !is_short_array?(t)
        @indent += 1
        t.each { |elem| newline; append_string(elem); @bld << COMMA_SEP }
        chomp_list
        @indent -= 1
        newline
      else
        append_strings(t)
      end
    end
  end

  # @api private
  def string_FalseClass(t); @bld << 'false'; end

  # @api private
  def string_Hash(t)
    append_hash(t)
  end

  # @api private
  def string_Module(t)
    append_string(TypeCalculator.singleton.type(t))
  end

  # @api private
  def string_NilClass(t); @bld << (@ruby ? 'nil' : 'undef'); end

  # @api private
  def string_Numeric(t); @bld << t.to_s; end

  # @api private
  def string_Regexp(t); @bld << PRegexpType.regexp_to_s_with_delimiters(t); end

  # @api private
  def string_String(t)
    # Use single qoute on strings that does not contain single quotes, control characters, or backslashes.
    @bld << StringConverter.singleton.puppet_quote(t)
  end

  # @api private
  def string_Symbol(t); @bld << t.to_s; end

  # @api private
  def string_TrueClass(t); @bld << 'true'; end

  # @api private
  def string_Version(t); @bld << "'#{t}'"; end

  # @api private
  def string_VersionRange(t); @bld << "'#{t}'"; end

  # @api private
  def string_Timespan(t); @bld << "'#{t}'"; end

  # @api private
  def string_Timestamp(t); @bld << "'#{t}'"; end

  # Debugging to_s to reduce the amount of output
  def to_s
    '[a TypeFormatter]'
  end

  NAME_SEGMENT_SEPARATOR = '::'
  STARTS_WITH_ASCII_CAPITAL = /^[A-Z]/

  # Capitalizes each segment in a name separated with the {NAME_SEPARATOR} conditionally. The name
  # will not be subject to capitalization if it already starts with a capital letter. This to avoid
  # that existing camel casing is lost.
  #
  # @param qualified_name [String] the name to capitalize
  # @return [String] the capitalized name
  #
  # @api private
  def capitalize_segments(qualified_name)
    if !qualified_name.is_a?(String) || qualified_name =~ STARTS_WITH_ASCII_CAPITAL
      qualified_name
    else
      segments = qualified_name.split(NAME_SEGMENT_SEPARATOR)
      if segments.size == 1
        qualified_name.capitalize
      else
        segments.each(&:capitalize!)
        segments.join(NAME_SEGMENT_SEPARATOR)
      end
    end
  end

  private

  COMMA_SEP = ', '

  HASH_ENTRY_OP = ' => '

  def is_short_array?(t)
    t.empty? || 100 - @indent * @indent_width > t.inject(0) do |sum, elem|
      case elem
      when true, false, nil, Numeric, Symbol
        sum + elem.inspect.length()
      when String
        sum + 2 + elem.length
      when Hash, Array
        sum + (elem.empty? ? 2 : 1000)
      else
        sum + 1000
      end
    end
  end

  def range_array_part(t)
    if t.nil? || t.unbounded?
      EMPTY_ARRAY
    else
      result = [t.from.nil? ? 'default' : t.from.to_s]
      result << t.to.to_s unless t.to.nil?
      result
    end
  end

  def append_object_hash(hash)
    @expanded = false
    append_array('Object') do
      append_hash(hash, proc { |k| @bld << symbolic_key(k) }) do |k, v|
        case k
        when KEY_ATTRIBUTES, KEY_FUNCTIONS
          # Types might need to be output as type references
          append_hash(v) do |_, fv|
            if fv.is_a?(Hash)
              append_hash(fv, proc { |fak| @bld << symbolic_key(fak) }) do |fak, fav|
                case fak
                when KEY_KIND
                  @bld << fav
                else
                  append_string(fav)
                end
              end
            else
              append_string(fv)
            end
          end
        when KEY_EQUALITY
          append_array('') { append_strings(v) } if v.is_a?(Array)
        else
          append_string(v)
        end
      end
    end
  ensure
    @expanded = true
  end

  def append_elements(array, to_be_continued = false)
    case array.size
    when 0
      # do nothing
    when 1
      @bld << array[0]
      @bld << COMMA_SEP if to_be_continued
    else
      array.each { |elem| @bld << elem << COMMA_SEP }
      chomp_list unless to_be_continued
    end
  end

  def append_strings(array, to_be_continued = false)
    case array.size
    when 0
      # do nothing
    when 1
      append_string(array[0])
      @bld << COMMA_SEP if to_be_continued
    else
      array.each do |elem|
        append_string(elem)
        @bld << COMMA_SEP
      end
      chomp_list unless to_be_continued
    end
  end

  def append_array(start, empty = false)
    @bld << start
    unless empty
      @bld << '['
      yield
      @bld << ']'
    end
  end

  def append_hash(hash, key_proc = nil)
    @bld << '{'
    @indent += 1 if @indent
    hash.each do |k, v|
      newline if @indent
      if key_proc.nil?
        append_string(k)
      else
        key_proc.call(k)
      end
      @bld << HASH_ENTRY_OP
      if block_given?
        yield(k, v)
      else
        append_string(v)
      end
      @bld << COMMA_SEP
    end
    chomp_list
    if @indent
      @indent -= 1
      newline
    end
    @bld << '}'
  end

  def newline
    @bld.rstrip!
    @bld << "\n"
    (@indent * @indent_width).times { @bld << ' ' }
  end

  def chomp_list
    @bld.chomp!(COMMA_SEP)
  end
end
end
end
