# Instantiates beans according to their scopes
class SmartIoC::BeanFactory
  include SmartIoC::Errors
  include SmartIoC::Args

  attr_reader :bean_file_loader

  def initialize(bean_definitions_storage, extra_package_contexts)
    @bean_definitions_storage = bean_definitions_storage
    @extra_package_contexts   = extra_package_contexts
    @bean_file_loader         = SmartIoC::BeanFileLoader.new
    @singleton_scope          = SmartIoC::Scopes::Singleton.new
    @prototype_scope          = SmartIoC::Scopes::Prototype.new
    @thread_scope             = SmartIoC::Scopes::Request.new
  end

  def clear_scopes
    all_scopes.each(&:clear)
  end

  def force_clear_scopes
    all_scopes.each(&:force_clear)
  end

  # Get bean from the container by it's name, package, context
  # @param bean_name [Symbol] bean name
  # @param package [Symbol] package name
  # @param context [Symbol] context
  # @return bean instance
  # @raise [ArgumentError] if bean is not found
  # @raise [ArgumentError] if ambiguous bean definition was found
  def get_bean(bean_name, package: nil, context: nil)
    check_arg(bean_name, :bean_name, Symbol)
    check_arg(package, :package, Symbol) if package
    check_arg(context, :context, Symbol) if context

    @bean_file_loader.require_bean(bean_name)

    context         = autodetect_context(bean_name, package, context)
    bean_definition = @bean_definitions_storage.find(bean_name, package, context)
    scope           = get_scope(bean_definition)
    bean            = scope.get_bean(bean_definition.klass)

    if bean
      update_dependencies(bean, bean_definition)
      bean
    else
      dependency_cache = {}
      beans_cache      = {}

      autodetect_bean_definitions_for_dependencies(bean_definition, dependency_cache)
      preload_beans(bean_definition, dependency_cache, beans_cache)
      load_bean(bean_definition, dependency_cache, beans_cache)
    end
  end

  private

  def update_dependencies(bean, bean_definition, updated_beans = {})
    bean_definition.dependencies.each do |dependency|
      bd = autodetect_bean_definition(
        dependency.ref, dependency.package, bean_definition.package
      )

      scope    = get_scope(bean_definition)
      dep_bean = updated_beans[bd] || scope.get_bean(bd.klass)

      if !dep_bean
        dep_bean = get_bean(
          bd.name, package: bd.package, context: bd.context
        )

        bean.instance_variable_set(:"@#{dependency.bean}", dep_bean)
        updated_beans[bd] = dep_bean
      else
        update_dependencies(dep_bean, bd, updated_beans)
      end
    end
  end

  def autodetect_context(bean_name, package, context)
    return context if context

    if package
      @extra_package_contexts.get_context(package)
    else
      bean_definition = autodetect_bean_definition(bean_name, package, nil)
      bean_definition.context
    end
  end

  def autodetect_bean_definitions_for_dependencies(bean_definition, dependency_cache)
    bean_definition.dependencies.each do |dependency|
      next if dependency_cache.has_key?(dependency)

      @bean_file_loader.require_bean(dependency.ref)

      bd = autodetect_bean_definition(
        dependency.ref, dependency.package, bean_definition.package
      )

      dependency_cache[dependency] = bd

      autodetect_bean_definitions_for_dependencies(bd, dependency_cache)
    end
  end

  def autodetect_bean_definition(bean, package, parent_bean_package)
    if package
      bean_context = @extra_package_contexts.get_context(package)
      bds = @bean_definitions_storage.filter_by_with_drop_to_default_context(bean, package, bean_context)

      return bds.first if bds.size == 1
      raise ArgumentError, "bean :#{bean} is not found in package :#{package}"
    end

    if parent_bean_package
      bean_context = @extra_package_contexts.get_context(parent_bean_package)
      bds = @bean_definitions_storage.filter_by_with_drop_to_default_context(bean, parent_bean_package, bean_context)

      return bds.first if bds.size == 1
    end

    bds = @bean_definitions_storage.filter_by(bean)
    bds_by_package = bds.group_by(&:package)
    smart_bds = []

    bds_by_package.each do |package, bd_list|
      # try to find bean definition with package context
      bd = bd_list.detect {|bd| bd.context == @extra_package_contexts.get_context(bd.package)}
      smart_bds << bd if bd

      # last try: find for :default context
      if !bd
        bd = bd_list.detect {|bd| bd.context == SmartIoC::Container::DEFAULT_CONTEXT}
        smart_bds << bd if bd
      end
    end

    if smart_bds.size > 1
      raise ArgumentError, "Unable to autodetect bean :#{bean}.\nSeveral definitions were found.\n#{smart_bds.map(&:inspect).join("\n\n")}. Set package directly for injected dependency"
    end

    if smart_bds.size == 0
      raise ArgumentError, "Unable to find bean :#{bean} in any package."
    end

    return smart_bds.first
  end

  def preload_beans(bean_definition, dependency_cache, beans_cache)
    scope = get_scope(bean_definition)

    if bean = scope.get_bean(bean_definition.klass)
      beans_cache[bean_definition] = bean
    else
      preload_bean_instance(bean_definition, beans_cache)
    end

    bean_definition.dependencies.each do |dependency|
      bd = dependency_cache[dependency]

      next if beans_cache.has_key?(bd)
      preload_beans(bd, dependency_cache, beans_cache)
    end
  end

  def preload_bean_instance(bean_definition, beans_cache)
    return beans_cache[bean_definition] if beans_cache.has_key?(bean_definition)

    scope = get_scope(bean_definition)
    bean = scope.get_bean(bean_definition.klass)

    if bean
      beans_cache[bean_definition] = bean
      return bean
    end

    bean = if bean_definition.is_instance?
      bean_definition.klass.allocate
    else
      bean_definition.klass
    end

    scope.save_bean(bean_definition.klass, bean)
    beans_cache[bean_definition] = bean

    bean
  end

  def load_bean(bean_definition, dependency_cache, beans_cache)
    # first let's setup beans with factory_methods
    zero_dep_bd_with_factory_methods = []
    bd_with_factory_methods = []

    beans_cache.each do |bean_definition, bean|
      if bean_definition.has_factory_method?
        if bean_definition.dependencies.size == 0
          zero_dep_bd_with_factory_methods << bean_definition
        else
          bd_with_factory_methods << bean_definition
        end
      end
    end

    bd_with_factory_methods.each do |bean_definition|
      if cross_refference_bd = get_cross_refference(bd_with_factory_methods, bean_definition, dependency_cache)
        raise ArgumentError, "Factory method beans should not cross refference each other. Bean :#{bean_definition.name} cross refferences bean :#{cross_refference_bd.name}."
      end
    end

    (zero_dep_bd_with_factory_methods + bd_with_factory_methods).each do |bean_definition|
      inject_beans(bean_definition, dependency_cache, beans_cache)
      bean = beans_cache[bean_definition]
      bean = bean.send(bean_definition.factory_method) if bean.is_a?(bean_definition.klass)
      beans_cache[bean_definition] = bean
      scope = get_scope(bean_definition)
      scope.save_bean(bean_definition.klass, bean)
    end

    inject_beans(bean_definition, dependency_cache, beans_cache)

    beans_cache[bean_definition]
  end

  def inject_beans(bean_definition, dependency_cache, beans_cache)
    bean = beans_cache[bean_definition]
    bean_definition.dependencies.each do |dependency|
      bd = dependency_cache[dependency]
      dep_bean = beans_cache[bd]
      bean.instance_variable_set(:"@#{dependency.bean}", dep_bean)
      inject_beans(bd, dependency_cache, beans_cache)
    end
  end

  def get_cross_refference(refer_bean_definitions, current_bean_definition, dependency_cache, seen_bean_definitions = [])
    current_bean_definition.dependencies.each do |dependency|
      bd = dependency_cache[dependency]

      next if seen_bean_definitions.include?(bd)

      if refer_bean_definitions.include?(bd)
        return bd
      end

      if crbd = get_cross_refference(refer_bean_definitions, bd, dependency_cache, seen_bean_definitions + [bd])
        return crbd
      end
    end

    nil
  end

  def all_scopes
    [@singleton_scope, @prototype_scope, @thread_scope]
  end

  def get_scope(bean_definition)
    case bean_definition.scope
    when SmartIoC::Scopes::Singleton::VALUE
      @singleton_scope
    when SmartIoC::Scopes::Prototype::VALUE
      @prototype_scope
    when SmartIoC::Scopes::Request::VALUE
      @thread_scope
    else
      raise ArgumentError, "bean definition for :#{bean_definition.name} has unsupported scope :#{bean_definition.scope}"
    end
  end
end