# frozen_string_literal: true

module ActiveRecord::Bitemporal
  module Visualizer
    # Figure is a two-dimensional array holding plotted lines and columns
    class Figure < Array
      def print(str, line: 0, column: 0)
        self[line] ||= []
        str.each_char.with_index(column) do |c, i|
          # The `#` represents a zero-length rectangle and should not be overwritten with lines
          next if self[line][i] == '#' && (c == '+' || c == '|' || c == '-')

          self[line][i] = c
        end
      end

      def to_s
        map { |l| l&.map { |c| c || ' ' }&.join }.join("\n")
      end
    end

    module_function

    def visualize(record, height: 10, width: 40, highlight: true)
      histories = record.class.ignore_bitemporal_datetime.bitemporal_for(record).order(:transaction_from, record.valid_from_key)

      if highlight
        visualize_records(histories, [record], height: height, width: width)
      else
        visualize_records(histories, height: height, width: width)
      end
    end

    # e.g. visualize_records(ActiveRecord::Relation, ActiveRecord::Relation)
    def visualize_records(*relations, height: 10, width: 40)
      raise 'More than 3 relations are not supported' if relations.size >= 3
      records = relations.flatten

      valid_times = (records.map { _1[_1.valid_from_key] } + records.map { _1[_1.valid_to_key] }).sort.uniq
      transaction_times = (records.map(&:transaction_from) + records.map(&:transaction_to)).sort.uniq
  
      time_length = Time.zone.now.strftime('%F %T.%3N').length
  
      columns = compute_positions(valid_times, length: width, left_margin: time_length + 1, outlier: ActiveRecord::Bitemporal::DEFAULT_VALID_TO)
      lines = compute_positions(transaction_times, length: height, outlier: ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_TO)
  
      headers = Figure.new
      valid_times.each_with_object([]).with_index do |(valid_time, prev_valid_times), line|
        prev_valid_times.each do |valid_time|
          headers.print('|', line: line, column: columns[valid_time])
        end
        valid_time_str = valid_time.kind_of?(Date) ? valid_time.strftime('%F') : valid_time.strftime('%F %T.%3N')
        headers.print("| #{valid_time_str}", line: line, column: columns[valid_time])
        prev_valid_times << valid_time
      end
  
      body = Figure.new
      relations.each.with_index do |relation, idx|
        filler = idx == 0 ? ' ' : '*'

        relation.each do |record|
          line = lines[record.transaction_from]
          valid_from = record[record.valid_from_key]
          valid_to = record[record.valid_to_key]
          column = columns[valid_from]
    
          width = columns[valid_to] - columns[valid_from] - 1
          height = lines[record.transaction_to] - lines[record.transaction_from] - 1
    
          body.print("#{record.transaction_from.strftime('%F %T.%3N')} ", line: line)
          if width > 0
            if height > 0
              body.print('+' + '-' * width + '+', line: line, column: column)
            else
              body.print('|' + '#' * width + '|', line: line, column: column)
            end
          else
            body.print('#', line: line, column: column)
          end

          1.upto(height) do |i|
            if width > 0
              body.print('|' + filler * width + '|', line: line + i, column: column)
            else
              body.print('#', line: line + i, column: column)
            end
          end

          body.print("#{record.transaction_to.strftime('%F %T.%3N')} ", line: line + height + 1)
          if width > 0
            body.print('+' + '-' * width + '+', line: line + height + 1, column: column)
          else
            body.print('#', line: line + height + 1, column: column)
          end
        end
      end

      valid_label = valid_times[0].kind_of?(Date) ? 'valid_date' : 'valid_datetime'
      transaction_label = 'transaction_datetime'
      right_margin = time_length + 1 - transaction_label.size

      label = if right_margin >= 0
        "#{transaction_label + ' ' * right_margin}| #{valid_label}"
      else
        "#{transaction_label[0...right_margin]}| #{valid_label}"
      end

      "#{label}\n#{headers.to_s}\n#{body.to_s}"
    end
  
    # Compute a dictionary of where each time should be plotted.
    # The position is normalized to the actual length of time.
    #
    # Example:
    #
    #   t1     t2     t3                t4
    #   |------|------|-----------------|
    #
    #   f(t1, t2, t3, t4) -> { t1 => 0, t2 => 2, t3 => 4, t4 => 10 }
    #
    def compute_positions(times, length:, left_margin: 0, outlier: nil)
      lengths_from_beginning = compute_lengths_from_beginning(times, outlier: outlier)
      # times must be sorted in ascending order. This is caller's responsibility.
      # In that case, the last of lengths_from_beginning is equal to the total length.
      total = lengths_from_beginning.values.last
  
      times.each_with_object({}) do |time, ret|
        prev = ret.values.last
        pos = (lengths_from_beginning[time] / total * length).to_i + left_margin
  
        if prev
          # If the difference of times is too short, a position that have already been plotted may be computed.
          # But we still want to plot the time, so allocate the required number to plot the smallest area.
          if pos <= prev
            # | -> |*|
            #       ^^ 2 columns
            pos = prev + 2
          elsif pos == prev + 1
            # || -> |*|
            #        ^ 1 column
            pos += 1
          end
        end
        ret[time] = pos
      end
    end
  
    # Example:
    #
    #   t1          t2          t3          t4
    #   |-----------|-----------|-----------|
    #   <-----------> l1
    #   <-----------------------> l2
    #   <-----------------------------------> l3
    #
    #   f([t1, t2, t3, t4]) -> { t1 => 0, t2 => l1, t3 => l2, t4 => l3 }
    #
    def compute_lengths_from_beginning(times, outlier: nil)
      times.each_with_object({}) do |time, ret|
        ret[time] = if time == outlier && times.size > 2
                      # If it contains an extremely large value such as 9999-12-31,
                      # that point will have a large effect on the visualization,
                      # so adjust the length so that it is half of the whole.
                      ret.values.last * 2
                    else
                      time - times.min
                    end
      end
    end
  end
end
