require "resource_support/aws/aws_singular_resource_mixin"
require "resource_support/aws/aws_backend_base"
require "aws-sdk-iam"

require "json" unless defined?(JSON)
require "set" unless defined?(Set)
require "uri" unless defined?(URI)

class AwsIamPolicy < Inspec.resource(1)
  name "aws_iam_policy"
  desc "Verifies settings for individual AWS IAM Policy"
  example <<~EXAMPLE
    describe aws_iam_policy('AWSSupportAccess') do
      it { should be_attached }
    end
  EXAMPLE
  supports platform: "aws"

  include AwsSingularResourceMixin

  attr_reader :arn, :attachment_count, :default_version_id

  # Note that we also accept downcases and symbol versions of these
  EXPECTED_CRITERIA = %w{
    Action
    Effect
    Resource
    Sid
  }.freeze

  UNIMPLEMENTED_CRITERIA = %w{
    Conditional
    NotAction
    NotPrincipal
    NotResource
    Principal
  }.freeze

  def to_s
    "Policy #{@policy_name}"
  end

  def attached?
    attachment_count > 0
  end

  def attached_users
    return @attached_users if defined? @attached_users

    fetch_attached_entities
    @attached_users
  end

  def attached_groups
    return @attached_groups if defined? @attached_groups

    fetch_attached_entities
    @attached_groups
  end

  def attached_roles
    return @attached_roles if defined? @attached_roles

    fetch_attached_entities
    @attached_roles
  end

  def attached_to_user?(user_name)
    attached_users.include?(user_name)
  end

  def attached_to_group?(group_name)
    attached_groups.include?(group_name)
  end

  def attached_to_role?(role_name)
    attached_roles.include?(role_name)
  end

  def policy
    return nil unless exists?
    return @policy if defined?(@policy)

    catch_aws_errors do
      backend = BackendFactory.create(inspec_runner)
      gpv_response = backend.get_policy_version(policy_arn: arn, version_id: default_version_id)
      @policy = JSON.parse(URI.decode_www_form_component(gpv_response.policy_version.document))
    end
    @policy
  end

  def statement_count
    return nil unless exists?

    # Typically it is an array of statements
    if policy["Statement"].is_a? Array
      policy["Statement"].count
    else
      # But if there is one statement, it is permissable to degenerate the array,
      # and place the statement as a hash directly under the 'Statement' key
      1
    end
  end

  def has_statement?(provided_criteria = {})
    return nil unless exists?

    raw_criteria = provided_criteria.dup # provided_criteria is used for output formatting - can't delete from it.
    criteria = has_statement__validate_criteria(raw_criteria)
    @normalized_statements ||= has_statement__normalize_statements
    statements = has_statement__focus_on_sid(@normalized_statements, criteria)
    statements.any? do |statement|
      true && \
        has_statement__effect(statement, criteria) && \
        has_statement__array_criterion(:action, statement, criteria) && \
        has_statement__array_criterion(:resource, statement, criteria)
    end
  end

  private

  def has_statement__validate_criteria(raw_criteria)
    recognized_criteria = {}
    EXPECTED_CRITERIA.each do |expected_criterion|
      [
        expected_criterion,
        expected_criterion.downcase,
        expected_criterion.to_sym,
        expected_criterion.downcase.to_sym,
      ].each do |variant|
        if raw_criteria.key?(variant)
          # Always store as downcased symbol
          recognized_criteria[expected_criterion.downcase.to_sym] = raw_criteria.delete(variant)
        end
      end
    end

    # Special message for valid, but unimplemented statement attributes
    UNIMPLEMENTED_CRITERIA.each do |unimplemented_criterion|
      [
        unimplemented_criterion,
        unimplemented_criterion.downcase,
        unimplemented_criterion.to_sym,
        unimplemented_criterion.downcase.to_sym,
      ].each do |variant|
        if raw_criteria.key?(variant)
          raise ArgumentError, "Criterion '#{unimplemented_criterion}' is not supported for performing have_statement queries."
        end
      end
    end

    # If anything is left, it's spurious
    unless raw_criteria.empty?
      raise ArgumentError, "Unrecognized criteria #{raw_criteria.keys.join(", ")} to have_statement.  Recognized criteria: #{EXPECTED_CRITERIA.join(", ")}"
    end

    # Effect has only 2 permitted values
    if recognized_criteria.key?(:effect)
      unless %w{Allow Deny}.include?(recognized_criteria[:effect])
        raise ArgumentError, "Criterion 'Effect' for have_statement must be one of 'Allow' or 'Deny' - got '#{recognized_criteria[:effect]}'"
      end
    end

    recognized_criteria
  end

  def has_statement__normalize_statements
    # Some single-statement policies place their statement
    # directly in policy['Statement'], rather than in an
    # Array within it.  See arn:aws:iam::aws:policy/AWSCertificateManagerReadOnly
    # Thus, coerce to Array.
    policy["Statement"] = [policy["Statement"]] if policy["Statement"].is_a? Hash
    policy["Statement"].map do |statement|
      # Coerce some values into arrays
      %w{Action Resource}.each do |field|
        if statement.key?(field)
          statement[field] = Array(statement[field])
        end
      end

      # Symbolize all keys
      statement.keys.each do |field|
        statement[field.downcase.to_sym] = statement.delete(field)
      end

      statement
    end
  end

  def has_statement__focus_on_sid(statements, criteria)
    return statements unless criteria.key?(:sid)

    sid_seek = criteria[:sid]
    statements.select do |statement|
      if sid_seek.is_a? Regexp
        statement[:sid] =~ sid_seek
      else
        statement[:sid] == sid_seek
      end
    end
  end

  def has_statement__effect(statement, criteria)
    !criteria.key?(:effect) || criteria[:effect] == statement[:effect]
  end

  def has_statement__array_criterion(crit_name, statement, criteria)
    return true unless criteria.key?(crit_name)

    check = criteria[crit_name]
    # This is an array due to normalize_statements
    # If it is nil, the statement does not have an entry for that dimension;
    # but since we were asked to match on it (on nothing), we
    # decide to never match
    values = statement[crit_name]
    return false if values.nil?

    if check.is_a?(String)
      # If check is a string, it only has to match one of the values
      values.any? { |v| v == check }
    elsif check.is_a?(Regexp)
      # If check is a regex, it only has to match one of the values
      values.any? { |v| v =~ check }
    elsif check.is_a?(Array) && check.all? { |c| c.is_a? String }
      # If check is an array of strings, perform setwise check
      Set.new(values) == Set.new(check)
    elsif check.is_a?(Array) && check.all? { |c| c.is_a? Regexp }
      # If check is an array of regexes, all values must match all regexes
      values.all? { |v| check.all? { |r| v =~ r } }
    else
      false
    end
  end

  def validate_params(raw_params)
    validated_params = check_resource_param_names(
      raw_params: raw_params,
      allowed_params: [:policy_name],
      allowed_scalar_name: :policy_name,
      allowed_scalar_type: String
    )

    if validated_params.empty?
      raise ArgumentError, "You must provide the parameter 'policy_name' to aws_iam_policy."
    end

    validated_params
  end

  def fetch_from_api
    backend = BackendFactory.create(inspec_runner)

    policy = nil
    pagination_opts = { max_items: 1000 }
    loop do
      api_result = backend.list_policies(pagination_opts)
      policy = api_result.policies.detect do |p|
        p.policy_name == @policy_name
      end
      break if policy # Found it!
      break unless api_result.is_truncated # Not found and no more results

      pagination_opts[:marker] = api_result.marker
    end

    @exists = !policy.nil?

    return unless @exists

    @arn = policy[:arn]
    @default_version_id = policy[:default_version_id]
    @attachment_count = policy[:attachment_count]
  end

  def fetch_attached_entities
    unless @exists
      @attached_groups = nil
      @attached_users  = nil
      @attached_roles  = nil
      return
    end
    backend = AwsIamPolicy::BackendFactory.create(inspec_runner)
    criteria = { policy_arn: arn }
    resp = nil
    catch_aws_errors do
      resp = backend.list_entities_for_policy(criteria)
    end
    @attached_groups = resp.policy_groups.map(&:group_name)
    @attached_users  = resp.policy_users.map(&:user_name)
    @attached_roles  = resp.policy_roles.map(&:role_name)
  end

  class Backend
    class AwsClientApi < AwsBackendBase
      BackendFactory.set_default_backend(self)
      self.aws_client_class = Aws::IAM::Client

      def get_policy_version(criteria)
        aws_service_client.get_policy_version(criteria)
      end

      def list_policies(criteria)
        aws_service_client.list_policies(criteria)
      end

      def list_entities_for_policy(criteria)
        aws_service_client.list_entities_for_policy(criteria)
      end
    end
  end
end