# frozen_string_literal: true

##
# The ByteObject module adds several attribute methods to any class that includes it, to aid in writing programs that
# require manipulating bytes of specific size. It also has helper methods to make working with binary files easier.
module ByteObject

  BIT_LENGTHS = [8, 16, 32, 64].freeze

  # @!visibility private
  def self.included(base)
    base.extend ByteAttributes
  end

  ##
  # Whenever ByteObject is included into a class, it also extends that class with the ByteAttributes module. This is
  # the module that bestows the byte-specific attribute methods.
  module ByteAttributes

    ##
    # @!method attr_u8bit(*keys)
    # Creates an 8-bit unsigned attribute reader and writer.

    # @!method attr_s8bit(*keys)
    # Creates an 8-bit signed attribute reader and writer.

    # @!method attr_u16bit(*keys)
    # Creates a 16-bit unsigned attribute reader and writer.

    # @!method attr_s16bit(*keys)
    # Creates an 16-bit signed attribute reader and writer.

    # @!method attr_u32bit(*keys)
    # Creates an 32-bit unsigned attribute reader and writer.

    # @!method attr_s32bit(*keys)
    # Creates an 32-bit signed attribute reader and writer.

    # @!method attr_u64bit(*keys)
    # Creates an 64-bit unsigned attribute reader and writer.

    # @!method attr_s64bit(*keys)
    # Creates an 64-bit signed attribute reader and writer.

    # @!method attr_8bitset(*keys)
    # Creates an attribute reader and writer representing an 8-element array of boolean values.

    # @!method attr_16bitset(*keys)
    # Creates an attribute reader and writer representing a 16-element array of boolean values.

    # @!method attr_32bitset(*keys)
    # Creates an attribute reader and writer representing a 32-element array of boolean values.

    # @!method attr_64bitset(*keys)
    # Creates an attribute reader and writer representing a 64-element array of boolean values.

    ##
    # This method creates an ordinary attribute reader and a specialized attribute writer for the given key. The writer
    # will clamp any values passed to the new attribute based on its size and whether or not it is signed.
    # Under normal circumstances, you will not need to call this method yourself. It is used by this module to generate
    # the attribute methods given to any extended classes.
    # @param key [Symbol] The name of the attribute.
    # @param size [Integer] The size, in *bits*, of the attribute.
    # @param signed [Boolean] Whether or not the attribute can be negative.
    # @return [void]
    def attr_byte(key, size, signed)
      attr_reader(key)

      bytesize = (2**size)
      min = signed ? -bytesize/2 : 0
      max = signed ? (bytesize/2) - 1 : bytesize - 1

      define_method("#{key}=") do |val|
        instance_variable_set("@#{key}", val.clamp(min, max))
      end
    end

    ##
    # This method creates an ordinary attribute reader and a specialized attribute writer for the given key. The writer
    # will attempt to convert its argument into an +Array+ (if it's not one already), and then force that array to be
    # the proper +size+. Unlike the other methods, the attribute methods generated by +attr_bitset+ are noisy -- they
    # will warn you if you have given them an +Array+ of an incorrect size.
    # Like +attr_byte+, you will not need to call this method yourself under normal circumstances. It is used by this
    # module to generate the attribute methods given to any extended classes.
    # @param key [Symbol] The name of the attribute.
    # @param size [Integer] The size, in *bits*, of the attribute.
    # @return [void]
    def attr_bitset(key, size)
      attr_reader(key)

      define_method("#{key}=") do |val|
        bitset = Array(val)

        if bitset.length < size
          warn "WARNING: bitset #{key}= given smaller array than expected (given length #{bitset.length}, expected length #{size})"
          bitset = bitset.fill(false, (bitset.length..(size-1)))
        elsif bitset.length > size
          warn "WARNING: bitset #{key}= given larger array than expected (given length #{bitset.length}, expected length #{size})"
          bitset = bitset.slice(0, size)
        end

        instance_variable_set("@#{key}", bitset)
      end
    end

    ##
    # This method creates an ordinary attribute reader and a specialized attribute writer for the given key. The writer
    # will attempt to coerce whatever is given to it to a string, force it to +ASCII_8BIT+ encoding, and then ensure it
    # is the proper size.
    # Unlike +attr_byte+, you are meant to use this method directly.
    # @param key [Symbol] The name of the attribute.
    # @param size [Integer] The size, in *bytes*, of the attribute.
    # @param pad_str [String] If the given string is smaller than +size+, it will be padded with this string.
    # @param pad_left [Boolean] If the given string is smaller than +size+, setting this to +true+ will pad the
    #   attribute from the left with +pad_str+. +false+ will pad the attribute from the right instead. This argument
    #   defaults to +true+.
    # @return [void]
    def attr_string(key, size, pad_right = true, pad_str = "\x20")
      attr_reader(key)

      define_method("#{key}=") do |val|
        string = val.to_s

        if string.length < size
          string = pad_right ? string.ljust(size, pad_str) : string.rjust(size, pad_str)
        elsif string.length > size
          string = string.slice(0, size)
        end

        instance_variable_set("@#{key}", string.to_s.encode(Encoding::ASCII_8BIT))
      end
    end

    # @!visibility private
    BIT_LENGTHS.each do |size|
      define_method("attr_u#{size}bit") do |*keys|
        keys.each{|key| attr_byte(key, size, false)}
      end

      define_method("attr_s#{size}bit") do |*keys|
        keys.each{|key| attr_byte(key, size, true)}
      end

      define_method("attr_#{size}bitset") do |*keys|
        keys.each{|key| attr_bitset(key, size)}
      end
    end

  end

  ##
  # Takes a hash (or hash-like object whose #each method yields a key-value pair) and assigns the values of its keys to
  # instance variables with the same names. If there is an attribute writer method, it will attempt to use that;
  # otherwise it will set the instance variable directly (much to the chagrin of whoever wrote the official Ruby
  # documentation).
  #
  # As the bang might suggest, this method is dangerous and modifies the calling object.
  # @param hash
  def from_hash!(hash)
    hash.each do |key, value|
      if methods.include?("#{key}=".to_sym)
        send("#{key}=", value)
      else
        instance_variable_set("@#{key}", value)
      end
    end
  end


end

class Test
  include ByteObject
end
