require_relative 'globals'

module Spaceship
  ##
  # Spaceship::Base is the superclass for models in Apple Developer Portal.
  # It's mainly responsible for mapping responses to objects.
  #
  # A class-level attribute `client` is used to maintain the spaceship which we
  # are using to talk to ADP.
  #
  # Example of creating a new ADP model:
  #
  #   class Widget < Spaceship::Base
  #     attr_accessor :id, :name, :foo_bar, :wiz_baz
  #     attr_mapping({
  #       'name' => :name,
  #       'fooBar' => :foo_bar,
  #       'wizBaz' => :wiz_baz
  #     })
  #   end
  #
  # When you want to instantiate a model pass in the parsed response: `Widget.new(widget_json)`
  class Base
    class DataHash
      include Enumerable

      def initialize(hash)
        @hash = hash || {}
      end

      def key?(key)
        @hash.key?(key)
      end

      def get(*keys)
        lookup(keys)
      end

      alias [] get

      def set(keys, value)
        raise "'keys' must be an array, got #{keys.class} instead" unless keys.kind_of?(Array)
        last = keys.pop
        ref = lookup(keys) || @hash
        ref[last] = value
      end

      def delete(key)
        @hash.delete(key)
      end

      def lookup(keys)
        head, *tail = *keys
        if tail.empty?
          @hash[head]
        else
          DataHash.new(@hash[head]).lookup(tail)
        end
      end

      def each(&block)
        @hash.each(&block)
      end

      def to_json(*a)
        h = @hash.dup
        h.delete(:application)
        h.to_json(*a)
      rescue JSON::GeneratorError => e
        puts("Failed to jsonify #{h} (#{a})") if Spaceship::Globals.verbose?
        raise e
      end

      def to_h
        @hash.dup
      end
    end

    class << self
      attr_accessor :client

      ##
      # The client used to make requests.
      # @return (Spaceship::Client) Defaults to the singleton
      def client
        raise "`client` must be implemented in subclasses"
      end

      ##
      # Sets client and returns self for chaining.
      # @return (Spaceship::Base)
      # rubocop:disable Naming/AccessorMethodName
      def set_client(client)
        self.client = client
        self
      end
      # rubocop:enable Naming/AccessorMethodName

      ##
      # Binds attributes getters and setters to underlying data returned from the API.
      # Setting any properties will alter the `raw_data` hash.
      #
      # @return (Module) with the mapped getters and setters defined. Can be `include`, `extend`, or `prepend` into a class or object
      def mapping_module(attr_mapping)
        Module.new do
          attr_mapping.each do |source, dest|
            getter = dest.to_sym
            setter = "#{dest}=".to_sym

            define_method(getter) do
              raw_data.get(*source.split('.'))
            end

            define_method(setter) do |value|
              self.raw_data ||= DataHash.new({})
              raw_data.set(source.split('.'), value)
            end
          end
        end
      end

      ##
      # Defines the attribute mapping between the response from Apple and our model objects.
      # Keys are to match keys in the response and the values are to match attributes on the model.
      #
      # Example of using `attr_mapping`
      #
      #   class Widget < Spaceship::Base
      #     attr_accessor :id, :name, :foo_bar, :wiz_baz
      #     attr_mapping({
      #       'name' => :name,
      #       'fooBar' => :foo_bar,
      #       'wizBaz' => :wiz_baz
      #     })
      #   end
      def attr_mapping(attr_map = nil)
        if attr_map
          @attr_mapping = attr_map
          @attr_mapping.values.each do |method_name|
            getter = method_name.to_sym
            setter = "#{method_name}=".to_sym

            # Seems like the `public_instance_methods.include?` doesn't always work
            # More context https://github.com/fastlane/fastlane/issues/11481
            # That's why we have the `begin` `rescue` code here
            begin
              remove_method(getter) if public_instance_methods.include?(getter)
            rescue NameError
            end
            begin
              remove_method(setter) if public_instance_methods.include?(setter)
            rescue NameError
            end
          end
          include(mapping_module(@attr_mapping))
        else
          begin
            @attr_mapping ||= ancestors[1].attr_mapping
          rescue NoMethodError
          rescue NameError
          end
        end
        return @attr_mapping
      end

      ##
      # Call a method to return a subclass constant.
      #
      # If `method_sym` is an underscored name of a class,
      # return the class with the current client passed into it.
      # If the method does not match, NoMethodError is raised.
      #
      # Example:
      #
      #   Certificate.production_push
      #   #=> Certificate::ProductionPush
      #
      #   ProvisioningProfile.ad_hoc
      #   #=> ProvisioningProfile::AdHoc
      #
      #   ProvisioningProfile.some_other_method
      #   #=> NoMethodError: undefined method `some_other_method' for ProvisioningProfile
      def method_missing(method_sym, *args, &block)
        module_name = method_sym.to_s
        module_name.sub!(/^[a-z\d]/) { $&.upcase }
        module_name.gsub!(%r{(?:_|(/))([a-z\d])}) { $2.upcase }
        if const_defined?(module_name)
          klass = const_get(module_name)
          klass.set_client(@client)
        else
          super
        end
      end

      ##
      # The factory class-method. This should only be used or overridden in very specific use-cases
      #
      # The only time it makes sense to use or override this method is when we want a base class
      # to return a sub-class based on attributes.
      #
      # Here, we define the method to be the same as `Spaceship::Base.new(attrs)`, be it should
      # be used only by classes that override it.
      #
      # Example:
      #
      #   Certificate.factory(attrs)
      #   #=> #<PushCertificate ... >
      #
      def factory(attrs, existing_client = nil)
        self.new(attrs, existing_client)
      end
    end

    ##
    # @return (Spaceship::Client) The current spaceship client used by the model to make requests.
    attr_reader :client

    ##
    # @return (Hash/Array) Holds the raw data we got from Apple's
    #   server to use it later
    attr_accessor :raw_data

    ##
    # The initialize method accepts a parsed response from Apple and sets all
    # attributes that are defined by `attr_mapping`
    #
    # Do not override `initialize` in your own models.
    def initialize(attrs = {}, existing_client = nil)
      attrs.each do |key, val|
        self.send("#{key}=", val) if respond_to?("#{key}=")
      end
      self.raw_data = DataHash.new(attrs)
      @client = existing_client || self.class.client
      self.setup
    end

    # This method can be used by subclasses to do additional initialisation
    # using the `raw_data`
    def setup; end

    #####################################################
    # @!group Storing the `attr_accessor`
    #####################################################

    # From https://stackoverflow.com/questions/2487333/fastest-one-liner-way-to-list-attr-accessors-in-ruby
    # This will store a list of defined attr_accessors to easily access them when inspecting the values
    def self.attr_accessor(*vars)
      @attributes ||= []
      @attributes.concat(vars)
      super(*vars)
    end

    def self.attributes
      @attributes ||= []
      par = []

      par = (self.superclass.attributes || []) unless self == Base

      @attributes + par
    end

    def attributes
      self.class.attributes
    end

    #####################################################
    # @!group Inspect related code
    #####################################################

    def inspect
      # To avoid circular references, we keep track of the references
      # of all objects already inspected from the first call to inspect
      # in this call stack
      # We use a Thread local storage for multi-thread friendliness
      thread = Thread.current
      tree_root = thread[:inspected_objects].nil?
      thread[:inspected_objects] = Set.new if tree_root

      if thread[:inspected_objects].include?(self)
        # already inspected objects have a default value,
        # let's follow Ruby's convention for circular references
        value = "#<Object ...>"
      else
        thread[:inspected_objects].add(self)
        begin
          value = inspect_value
        ensure
          thread[:inspected_objects] = nil if tree_root
        end
      end

      "<#{self.class.name} \n#{value}>"
    end

    def inspect_value
      self.attributes.map do |k|
        v = self.send(k).inspect
        v = v.gsub("\n", "\n\t") # to align nested elements

        "\t#{k}=#{v}"
      end.join(", \n")
    end

    def to_s
      self.inspect
    end

    private :inspect_value
  end
end
