# frozen_string_literal: true

module MemoryProfiler
  class Results
    UNIT_PREFIXES = {
      0 => 'B',
      3 => 'kB',
      6 => 'MB',
      9 => 'GB',
      12 => 'TB',
      15 => 'PB',
      18 => 'EB',
      21 => 'ZB',
      24 => 'YB'
    }.freeze

    TYPES   = ["allocated", "retained"].freeze
    METRICS = ["memory", "objects"].freeze
    NAMES   = ["gem", "file", "location", "class"].freeze

    def self.register_type(name, stat_attribute)
      @@lookups ||= []
      @@lookups << [name, stat_attribute]

      TYPES.each do |type|
        METRICS.each do |metric|
          attr_accessor "#{type}_#{metric}_by_#{name}"
        end
      end
    end

    register_type 'gem', :gem
    register_type 'file', :file
    register_type 'location', :location
    register_type 'class', :class_name

    attr_accessor :strings_retained, :strings_allocated
    attr_accessor :total_retained, :total_allocated
    attr_accessor :total_retained_memsize, :total_allocated_memsize

    def register_results(allocated, retained, top)

      @@lookups.each do |name, stat_attribute|

        memsize_results, count_results = allocated.top_n(top, stat_attribute)

        self.send("allocated_memory_by_#{name}=", memsize_results)
        self.send("allocated_objects_by_#{name}=", count_results)

        memsize_results, count_results = retained.top_n(top, stat_attribute)

        self.send("retained_memory_by_#{name}=", memsize_results)
        self.send("retained_objects_by_#{name}=", count_results)
      end

      self.strings_allocated = string_report(allocated, top)
      self.strings_retained = string_report(retained, top)

      self.total_allocated = allocated.size
      self.total_allocated_memsize = allocated.values.map!(&:memsize).inject(0, :+)
      self.total_retained = retained.size
      self.total_retained_memsize = retained.values.map!(&:memsize).inject(0, :+)

      self
    end

    def scale_bytes(bytes)
      return "0 B" if bytes.zero?

      scale = Math.log10(bytes).div(3) * 3
      scale = 24 if scale > 24
      "%.2f #{UNIT_PREFIXES[scale]}" % (bytes / 10.0**scale)
    end

    def string_report(data, top)
      grouped_strings = data.values
        .keep_if  { |stat| stat.string_value }
        .group_by { |stat| stat.string_value.object_id }
        .values

      if grouped_strings.size > top
        cutoff = grouped_strings.sort_by!(&:size)[-top].size
        grouped_strings.keep_if { |list| list.size >= cutoff }
      end

      grouped_strings
        .sort! { |a, b| a.size == b.size ? a[0].string_value <=> b[0].string_value : b.size <=> a.size }
        .first(top)
        .map! do |list|
          # Return array of [string, [[location, count], [location, count], ...]
          [
            list[0].string_value,
            list.group_by { |stat| stat.location }
              .map { |location, stat_list| [location, stat_list.size] }
              .sort_by!(&:last)
              .reverse!
          ]
        end
    end

    # Output the results of the report
    # @param [Hash] options the options for output
    # @option opts [String] :to_file a path to your log file
    # @option opts [Boolean] :color_output a flag for whether to colorize output
    # @option opts [Integer] :retained_strings how many retained strings to print
    # @option opts [Integer] :allocated_strings how many allocated strings to print
    # @option opts [Boolean] :detailed_report should report include detailed information
    # @option opts [Boolean] :scale_bytes calculates unit prefixes for the numbers of bytes
    # @option opts [Boolean] :normalize_paths print location paths relative to gem's source directory.
    def pretty_print(io = $stdout, **options)
      # Handle the special case that Ruby PrettyPrint expects `pretty_print`
      # to be a customized pretty printing function for a class
      return io.pp_object(self) if defined?(PP) && io.is_a?(PP)

      io = File.open(options[:to_file], "w") if options[:to_file]

      color_output = options.fetch(:color_output) { io.respond_to?(:isatty) && io.isatty }
      @colorize = color_output ? Polychrome.new : Monochrome.new

      if options[:scale_bytes]
        total_allocated_output = scale_bytes(total_allocated_memsize)
        total_retained_output  = scale_bytes(total_retained_memsize)
      else
        total_allocated_output = "#{total_allocated_memsize} bytes"
        total_retained_output  = "#{total_retained_memsize} bytes"
      end

      io.puts "Total allocated: #{total_allocated_output} (#{total_allocated} objects)"
      io.puts "Total retained:  #{total_retained_output} (#{total_retained} objects)"

      unless options[:detailed_report] == false
        TYPES.each do |type|
          METRICS.each do |metric|
            NAMES.each do |name|
              dump_data(io, type, metric, name, options)
            end
          end
        end
      end

      io.puts
      print_string_reports(io, options)

      io.close if io.is_a? File
    end

    def print_string_reports(io, options)
      TYPES.each do |type|
        dump_opts = {
          normalize_paths: options[:normalize_paths],
          limit: options["#{type}_strings".to_sym]
        }
        dump_strings(io, type, dump_opts)
      end
    end

    def normalize_path(path)
      @normalize_path ||= {}
      @normalize_path[path] ||= begin
        if %r!(/gems/.*)*/gems/(?<gemname>[^/]+)(?<rest>.*)! =~ path
          "#{gemname}#{rest}"
        elsif %r!ruby/2\.[^/]+/(?<stdlib>[^/.]+)(?<rest>.*)! =~ path
          "ruby/lib/#{stdlib}#{rest}"
        elsif %r!(?<app>[^/]+/(bin|app|lib))(?<rest>.*)! =~ path
          "#{app}#{rest}"
        else
          path
        end
      end
    end

    private

    def print_title(io, title)
      io.puts
      io.puts title
      io.puts @colorize.line("-----------------------------------")
    end

    def print_output(io, topic, detail)
      io.puts "#{@colorize.path(topic.to_s.rjust(10))}  #{detail}"
    end

    def dump_data(io, type, metric, name, options)
      print_title  io, "#{type} #{metric} by #{name}"
      data = self.send "#{type}_#{metric}_by_#{name}"

      scale_data = metric == "memory" && options[:scale_bytes]
      normalize_paths = options[:normalize_paths]

      if data && !data.empty?
        data.each do |item|
          count = scale_data ? scale_bytes(item[:count]) : item[:count]
          value = normalize_paths ? normalize_path(item[:data]) : item[:data]
          print_output io, count, value
        end
      else
        io.puts "NO DATA"
      end

      nil
    end

    def dump_strings(io, type, options)
      strings = self.send("strings_#{type}") || []
      return if strings.empty?

      options = {} unless options.is_a?(Hash)

      if (limit = options[:limit])
        return if limit == 0
        strings = strings[0...limit]
      end

      normalize_paths = options[:normalize_paths]

      print_title(io, "#{type.capitalize} String Report")
      strings.each do |string, stats|
        print_output io, (stats.reduce(0) { |a, b| a + b[1] }), @colorize.string(string.inspect)
        stats.sort_by { |x, y| [-y, x] }.each do |location, count|
          location = normalize_path(location) if normalize_paths
          print_output io, count, location
        end
        io.puts
      end

      nil
    end

  end

end
