class Miam::Client
  include Miam::Logger::Helper

  def initialize(options = {})
    @options = {
      format: :ruby,
      exclude: []
    }.merge(options)
    aws_config = options.delete(:aws_config) || {}
    @iam = Aws::IAM::Client.new(aws_config)
    @sts = Aws::STS::Client.new(aws_config)
    @driver = Miam::Driver.new(@iam, @sts, options)
    @password_manager = options[:password_manager] || Miam::PasswordManager.new('-', options)
  end

  def export(export_options = {})
    exported, group_users, instance_profile_roles = Miam::Exporter.export(@iam, @options)
    exported.sort_array!

    if block_given?
      [:users, :groups, :roles, :instance_profiles, :policies].each do |type|
        splitted = {:users => {}, :groups => {}, :roles => {}, :instance_profiles => {}, :policies => {}}

        if export_options[:split_more]
          exported[type].sort_by {|k, v| k }.each do |name, attrs|
            more_splitted = splitted.dup
            more_splitted[type] = {}
            more_splitted[type][name] = attrs

            dsl = exec_by_format(
              :ruby => proc { Miam::DSL.convert(more_splitted, @options).strip },
              :json => proc { JSON.pretty_generate(more_splitted) }
            )

            yield(:type => type, :name => name, :dsl => dsl)
          end
        else
          splitted[type] = exported[type]

          dsl = exec_by_format(
            :ruby => proc { Miam::DSL.convert(splitted, @options).strip },
            :json => proc { JSON.pretty_generate(splitted) }
          )

          yield(:type => type, :dsl => dsl)
        end
      end
    else
      dsl = exec_by_format(
        :ruby => proc { Miam::DSL.convert(exported, @options).strip },
        :json => proc { JSON.pretty_generate(exported) }
      )
    end
  end

  def apply(file)
    walk(file)
  end

  private

  def walk(file)
    expected = load_file(file)
    @options[:exclude] += expected[:exclude]

    actual, group_users, instance_profile_roles = Miam::Exporter.export(@iam, @options)
    updated = pre_walk_managed_policies(expected[:policies], actual[:policies])
    updated = walk_groups(expected[:groups], actual[:groups], actual[:users], group_users) || updated
    updated = walk_users(expected[:users], actual[:users], group_users) || updated
    updated = walk_instance_profiles(expected[:instance_profiles], actual[:instance_profiles], actual[:roles], instance_profile_roles) || updated
    updated = walk_roles(expected[:roles], actual[:roles], instance_profile_roles) || updated
    updated = post_walk_managed_policies(actual[:policies]) || updated

    if @options[:dry_run]
      false
    else
      updated
    end
  end

  def walk_users(expected, actual, group_users)
    updated = scan_rename(:user, expected, actual, group_users)

    expected.each do |user_name, expected_attrs|
      next unless target_matched?(user_name)

      actual_attrs = actual.delete(user_name)

      if actual_attrs
        updated = walk_path(:user, user_name, expected_attrs[:path], actual_attrs[:path]) || updated
        updated = walk_user(user_name, expected_attrs, actual_attrs) || updated
      else
        actual_attrs = @driver.create_user(user_name, expected_attrs)
        access_key = @driver.create_access_key(user_name) unless @options[:no_access_key]

        if access_key
          @password_manager.puts_password(user_name, access_key[:access_key_id], access_key[:secret_access_key])
        end

        walk_user(user_name, expected_attrs, actual_attrs)
        updated = true
      end
    end

    actual.each do |user_name, attrs|
      next unless target_matched?(user_name)

      @driver.delete_user(user_name, attrs)

      group_users.each do |group_name, users|
        users.delete(user_name)
      end

      updated = true
    end

    updated
  end

  def walk_user(user_name, expected_attrs, actual_attrs)
    updated = walk_login_profile(user_name, expected_attrs[:login_profile], actual_attrs[:login_profile])
    updated = walk_user_groups(user_name, expected_attrs[:groups], actual_attrs[:groups]) || updated
    updated = walk_attached_managed_policies(:user, user_name, expected_attrs[:attached_managed_policies], actual_attrs[:attached_managed_policies]) || updated
    walk_policies(:user, user_name, expected_attrs[:policies], actual_attrs[:policies]) || updated
  end

  def walk_login_profile(user_name, expected_login_profile, actual_login_profile)
    updated = false

    [expected_login_profile, actual_login_profile].each do |login_profile|
      if login_profile and not login_profile.has_key?(:password_reset_required)
        login_profile[:password_reset_required] = false
      end
    end

    if expected_login_profile and not actual_login_profile
      expected_login_profile[:password] ||= @password_manager.identify(user_name, :login_profile, @driver.password_policy)
      @driver.create_login_profile(user_name, expected_login_profile)
      updated = true
    elsif not expected_login_profile and actual_login_profile
      @driver.delete_login_profile(user_name)
      updated = true
    elsif expected_login_profile != actual_login_profile
      if @options[:ignore_login_profile]
        log(:warn, "User `#{user_name}`: difference of login profile has been ignored: expected=#{expected_login_profile.inspect}, actual=#{actual_login_profile.inspect}", :color => :yellow)
      else
        @driver.update_login_profile(user_name, expected_login_profile, actual_login_profile)
        updated = true
      end
    end

    updated
  end

  def walk_user_groups(user_name, expected_groups, actual_groups)
    expected_groups = expected_groups.sort
    actual_groups = actual_groups.sort
    updated = false

    if expected_groups != actual_groups
      add_groups = expected_groups - actual_groups
      remove_groups = actual_groups - expected_groups

      unless add_groups.empty?
        @driver.add_user_to_groups(user_name, add_groups)
      end

      unless remove_groups.empty?
        @driver.remove_user_from_groups(user_name, remove_groups)
      end

      updated = true
    end

    updated
  end

  def walk_groups(expected, actual, actual_users, group_users)
    updated = scan_rename(:group, expected, actual, group_users)

    expected.each do |group_name, expected_attrs|
      next unless target_matched?(group_name)

      actual_attrs = actual.delete(group_name)

      if actual_attrs
        updated = walk_path(:group, group_name, expected_attrs[:path], actual_attrs[:path]) || updated
        updated = walk_group(group_name, expected_attrs, actual_attrs) || updated
      else
        actual_attrs = @driver.create_group(group_name, expected_attrs)
        walk_group(group_name, expected_attrs, actual_attrs)
        updated = true
      end
    end

    actual.each do |group_name, attrs|
      next unless target_matched?(group_name)

      users_in_group = group_users.delete(group_name) || []
      @driver.delete_group(group_name, attrs, users_in_group)

      actual_users.each do |user_name, user_attrs|
        user_attrs[:groups].delete(group_name)
      end

      updated = true
    end

    updated
  end

  def walk_group(group_name, expected_attrs, actual_attrs)
    updated = walk_policies(:group, group_name, expected_attrs[:policies], actual_attrs[:policies])
    walk_attached_managed_policies(:group, group_name, expected_attrs[:attached_managed_policies], actual_attrs[:attached_managed_policies]) || updated
  end

  def walk_roles(expected, actual, instance_profile_roles)
    updated = false

    expected.each do |role_name, expected_attrs|
      next unless target_matched?(role_name)

      actual_attrs = actual.delete(role_name)

      if actual_attrs
        updated = walk_role(role_name, expected_attrs, actual_attrs) || updated
      else
        actual_attrs = @driver.create_role(role_name, expected_attrs)
        walk_role(role_name, expected_attrs, actual_attrs)
        updated = true
      end
    end

    actual.each do |role_name, attrs|
      next unless target_matched?(role_name)

      instance_profile_names = []

      instance_profile_roles.each do |instance_profile_name, roles|
        if roles.include?(role_name)
          instance_profile_names << instance_profile_name
        end
      end

      @driver.delete_role(role_name, instance_profile_names, attrs)

      instance_profile_roles.each do |instance_profile_name, roles|
        roles.delete(role_name)
      end

      updated = true
    end

    updated
  end

  def walk_role(role_name, expected_attrs, actual_attrs)
    if expected_attrs.values_at(:path) != actual_attrs.values_at(:path)
      log(:warn, "Role `#{role_name}`: 'path' cannot be updated", :color => :yellow)
    end

    updated = walk_role_settings(role_name, {max_session_duration: expected_attrs[:max_session_duration]}, {max_session_duration: actual_attrs[:max_session_duration]})
    updated = walk_assume_role_policy(role_name, expected_attrs[:assume_role_policy_document], actual_attrs[:assume_role_policy_document]) || updated
    updated = walk_role_instance_profiles(role_name, expected_attrs[:instance_profiles], actual_attrs[:instance_profiles]) || updated
    updated = walk_attached_managed_policies(:role, role_name, expected_attrs[:attached_managed_policies], actual_attrs[:attached_managed_policies]) || updated
    walk_policies(:role, role_name, expected_attrs[:policies], actual_attrs[:policies]) || updated
  end

  def walk_role_settings(role_name, expected_settings, actual_settings)
    updated = false

    if expected_settings != actual_settings
      @driver.update_role_settings(role_name, expected_settings, actual_settings)
      updated = true
    end

    updated
  end

  def walk_assume_role_policy(role_name, expected_assume_role_policy, actual_assume_role_policy)
    updated = false
    expected_assume_role_policy.sort_array!
    actual_assume_role_policy.sort_array!

    # With only one entity granted
    # On IAM
    #   (1) Statement => [ { Principal => AWS => arn } ]
    # Should be able to specify like:
    #   (2) Statement => [ { Principal => AWS => [arn] } ]
    # Actually (1) is reflected when config (2) is applied
    expected_arp_stmt = expected_assume_role_policy.fetch('Statement', [])
    expected_arp_stmt = expected_arp_stmt.select {|i| i.key?('Principal') }

    expected_arp_stmt.each do |stmt|
      stmt['Principal'].each do |k, v|
        entities = Array(v)
        stmt['Principal'][k] = entities.first if entities.length < 2
      end
    end

    if expected_assume_role_policy != actual_assume_role_policy
      @driver.update_assume_role_policy(role_name, expected_assume_role_policy, actual_assume_role_policy)
      updated = true
    end

    updated
  end

  def walk_role_instance_profiles(role_name, expected_instance_profiles, actual_instance_profiles)
    expected_instance_profiles = expected_instance_profiles.sort
    actual_instance_profiles = actual_instance_profiles.sort
    updated = false

    if expected_instance_profiles != actual_instance_profiles
      add_instance_profiles = expected_instance_profiles - actual_instance_profiles
      remove_instance_profiles = actual_instance_profiles - expected_instance_profiles

      unless add_instance_profiles.empty?
        @driver.add_role_to_instance_profiles(role_name, add_instance_profiles)
      end

      unless remove_instance_profiles.empty?
        @driver.remove_role_from_instance_profiles(role_name, remove_instance_profiles)
      end

      updated = true
    end

    updated
  end

  def walk_instance_profiles(expected, actual, actual_roles, instance_profile_roles)
    updated = false

    expected.each do |instance_profile_name, expected_attrs|
      next unless target_matched?(instance_profile_name)

      actual_attrs = actual.delete(instance_profile_name)

      if actual_attrs
        updated = walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs) || updated
      else
        actual_attrs = @driver.create_instance_profile(instance_profile_name, expected_attrs)
        walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs)
        updated = true
      end
    end

    actual.each do |instance_profile_name, attrs|
      next unless target_matched?(instance_profile_name)

      roles_in_instance_profile = instance_profile_roles.delete(instance_profile_name) || []
      @driver.delete_instance_profile(instance_profile_name, attrs, roles_in_instance_profile)

      actual_roles.each do |role_name, role_attrs|
        role_attrs[:instance_profiles].delete(instance_profile_name)
      end

      updated = true
    end

    updated
  end

  def walk_instance_profile(instance_profile_name, expected_attrs, actual_attrs)
    updated = false

    if expected_attrs != actual_attrs
      log(:warn, "InstanceProfile `#{instance_profile_name}`: 'path' cannot be updated", :color => :yellow)
    end

    updated
  end

  def scan_rename(type, expected, actual, group_users)
    updated = false

    expected.each do |name, expected_attrs|
      renamed_from = expected_attrs[:renamed_from]
      next unless renamed_from

      actual_attrs = actual.delete(renamed_from)
      next unless actual_attrs

      @driver.update_name(type, renamed_from, name)
      actual[name] = actual_attrs

      case type
      when :user
        group_users.each do |group_name, users|
          users.each do |user_name|
            if user_name == renamed_from
              user_name.replace(name)
            end
          end
        end
      when :group
        users = group_users.delete(renamed_from)
        group_users[name] = users if users
      end

      updated = true
    end

    updated
  end

  def walk_path(type, user_or_group_name, expected_path, actual_path)
    updated = false

    if expected_path != actual_path
      @driver.update_path(type, user_or_group_name, expected_path, actual_path)
      updated = true
    end

    updated
  end

  def walk_policies(type, user_or_group_name, expected_policies, actual_policies)
    updated = false

    expected_policies.each do |policy_name, expected_document|
      actual_document = actual_policies.delete(policy_name)

      if actual_document
        updated = walk_policy(type, user_or_group_name, policy_name, expected_document, actual_document) || updated
      else
        @driver.create_policy(type, user_or_group_name, policy_name, expected_document)
        updated = true
      end
    end

    actual_policies.each do |policy_name, document|
      @driver.delete_policy(type, user_or_group_name, policy_name)
      updated = true
    end

    updated
  end

  def walk_policy(type, user_or_group_name, policy_name, expected_document, actual_document)
    updated = false
    expected_document.sort_array!
    actual_document.sort_array!

    if expected_document != actual_document
      @driver.update_policy(type, user_or_group_name, policy_name, expected_document, actual_document)
      updated = true
    end

    updated
  end

  def walk_attached_managed_policies(type, name, expected_attached_managed_policies, actual_attached_managed_policies)
    expected_attached_managed_policies = expected_attached_managed_policies.sort
    actual_attached_managed_policies = actual_attached_managed_policies.sort
    updated = false

    if expected_attached_managed_policies != actual_attached_managed_policies
      add_attached_managed_policies = expected_attached_managed_policies - actual_attached_managed_policies
      remove_attached_managed_policies = actual_attached_managed_policies - expected_attached_managed_policies

      unless add_attached_managed_policies.empty?
        @driver.attach_policies(type, name, add_attached_managed_policies)
      end

      unless remove_attached_managed_policies.empty?
        @driver.detach_policies(type, name, remove_attached_managed_policies)
      end

      updated = true
    end

    updated
  end

  def pre_walk_managed_policies(expected, actual)
    updated = false

    expected.each do |policy_name, expected_attrs|
      next unless target_matched?(policy_name)
      actual_attrs = actual.delete(policy_name)

      if actual_attrs
        if expected_attrs[:path] != actual_attrs[:path]
          log(:warn, "ManagedPolicy `#{policy_name}`: 'path' cannot be updated", :color => :yellow)
        end

        updated = walk_managed_policy(policy_name, actual_attrs[:path], expected_attrs[:document], actual_attrs[:document]) || updated
      else
        @driver.create_managed_policy(policy_name, expected_attrs)
        updated = true
      end
    end

    updated
  end

  def walk_managed_policy(policy_name, policy_path, expected_document, actual_document)
    updated = false
    expected_document.sort_array!
    actual_document.sort_array!

    if expected_document != actual_document
      @driver.update_managed_policy(policy_name, policy_path, expected_document, actual_document)
      updated = true
    end

    updated
  end

  def post_walk_managed_policies(actual)
    updated = false

    actual.each do |policy_name, actual_attrs|
      next unless target_matched?(policy_name)
      @driver.delete_managed_policy(policy_name, actual_attrs[:path])
      updated = true
    end

    updated
  end

  def load_file(file)
    if file.kind_of?(String)
      open(file) do |f|
        exec_by_format(
          :ruby => proc { Miam::DSL.parse(f.read, file) },
          :json => proc { load_json(f) }
        )
      end
    elsif file.respond_to?(:read)
      exec_by_format(
        :ruby => proc { Miam::DSL.parse(file.read, file.path) },
        :json => proc { load_json(f) }
      )
    else
      raise TypeError, "can't convert #{file} into File"
    end
  end

  def target_matched?(name)
    result = true

    if @options[:exclude]
      result &&= @options[:exclude].all? {|r| name !~ r }
    end

    if @options[:target]
      result &&= @options[:target].any? {|r| name =~ r}
    end

    result
  end

  def exec_by_format(proc_by_format)
    format_proc = proc_by_format[@options[:format]]
    raise "Invalid format: #{@options[:format]}" unless format_proc
    format_proc.call
  end

  def load_json(json)
    json = JSON.load(json)
    normalized = {}

    json.each do |top_key, top_value|
      normalized[top_key.to_sym] = top_attrs = {}

      top_value.each do |second_key, second_value|
        top_attrs[second_key] = second_attrs = {}

        second_value.each do |third_key, third_value|
          third_key = third_key.to_sym

          if third_key == :login_profile
            new_third_value = {}
            third_value.each {|k, v| new_third_value[k.to_sym] = v }
            third_value = new_third_value
          end

          second_attrs[third_key] = third_value
        end
      end
    end

    normalized
  end
end
