require "history_client"
require 'aws-sdk-sns'

class AccountRecoveryJob < ResqueJobs::Base
  SLEEP_DURATION_SECONDS = 0.1

  AUDIT_ACTION_UPDATE = "update"
  AUDIT_ACTION_REVOKE_SESSION = "revoke_session"
  AUDIT_ACTION_RECOVERY_INVALIDATION = "recovery_invalidation"
  AUDIT_LDAP_USER = "ldap_user"
  AUDIT_TWITCH_USER = "twitch_user"
  AUDIT_DEFAULT_EXPIRY = 1 * 60 * 60 * 24 * 365 # 1 year

  READ_TIMEOUT = 10
  OPEN_TIMEOUT = 0.5
  TOTAL_TIMEOUT = OPEN_TIMEOUT + READ_TIMEOUT

  BASE_BACKOFF_SECONDS = SLEEP_DURATION_SECONDS
  MAX_BACKOFF_SECONDS = 10

  ACCOUNT_RECOVERY_EVENT = "account_recovery"
  PASSWORD_RESET_EVENT = "password_reset_flagged"

  TOPIC = Aws::SNS::Resource.new(region: 'us-west-2').topic(Settings.pushy.arn)

  # perform parses a job spec for recovering compromised accounts. It accepts
  # the following inputs in the options hash:
  # "users" - a list of Twitch user IDs for whom to recover accounts
  # "reporter" - the LDAP username of the admin requesting the job
  #
  # The job executes in phases and fails completely if any of the phases fails to complete
  # 1) Force password reset on next login
  # 2) Delete all outstanding OAuth2 tokens for the batch of users
  # 3) Invalidate OAuth2 Authorizations
  # 4) Delete all active authenticated sessions for the users (force logout)
  # 5) Invalidate facebook connection
  def perform
    # Parse out the job spec
    users = options["users"]
    reporter = options["reporter"]
    batch_id = "#{users.first}-#{users.last}"

    # 1) Force Password Reset
    unless force_password_reset(users, reporter)
      return fail_job(batch_id, 'Failed to force password resets')
    end

    # 2) Delete all outstanding OAuth2 tokens for the batch of users
    unless delete_oauth2_tokens(users)
      return fail_job(batch_id, 'Failed to delete OAuth2 tokens')
    end
    set_status('Completed deletion of OAuth2 tokens')

    # 3) Invalidate all OAuth2 authorizations
    unless invalidate_authorizations(users)
      return fail_job(batch_id, 'Failed to invalidate authorizations')
    end
    set_status('Completed invalidation of OAuth2 authorizations')

    # 4) Delete all active authenticated sessions for the users (force logout)
    unless force_user_logout(users, reporter)
      return fail_job(batch_id, 'Failed to force user logout')
    end
    set_status('Successfully logged accounts out')

    # 5) Invalidate facebook connection
    unless invalidate_facebook_connection(users)
      return fail_job(batch_id, 'Failed to invalidate facebook connections')
    end
    set_status('Successfully invalidated facebook connections')

    # 6) Invalidate sso connections
    unless invalidate_sso(users)
      return fail_job(batch_id, 'Failed to invalidate sso connections')
    end
    set_status('Successfully invalidated sso connections')
    audit_recovery_invalidation(users, reporter)
    completed
  end

  # delete_oauth2_tokens takes a list of Twitch user IDs and issues individual
  # requests to Owl to delete all of the outstanding OAuth2 tokens for each
  # user. This function retries failed requests individually.
  # Returns false if any user in the batch fails. Returns true when all deletes succeed
  def delete_oauth2_tokens(users)
    connection = owl_connection
    users.each do |user_id|
      # Reset initial state for each user
      current_backoff_seconds = BASE_BACKOFF_SECONDS
      loop do
        resp = connection.delete do |req|
          req.url "/v2/owner/#{user_id}/sessions"
        end
        if resp.success?
          break
        else
          sleep current_backoff_seconds
          current_backoff_seconds = current_backoff_seconds * 2
        end
        return false if current_backoff_seconds > MAX_BACKOFF_SECONDS
      end
    end
    true
  end

  def list_authorizations(user_id)
    current_backoff_seconds = BASE_BACKOFF_SECONDS
    connection = owl_connection
    loop do
      resp = connection.get do |req|
        req.url "/authorizations?owner_id=#{user_id}"
      end
      if resp.success?
        response = JSON.parse(resp.body)
        return response['authorizations']
      else
        sleep current_backoff_seconds
        current_backoff_seconds = current_backoff_seconds * 2
      end
      return [] if current_backoff_seconds > MAX_BACKOFF_SECONDS
    end
  end

  def invalidate_authorizations(users)
    connection = owl_connection
    users.each do |user_id|
      current_backoff_seconds = BASE_BACKOFF_SECONDS
      # Reset initial state for each user
      authorizations = list_authorizations(user_id)
      authorizations.each do |authorization|
        loop do
          client_id = authorization['client_id_canonical']
          owner_id = authorization['owner_id']
          resp = connection.delete do |req|
            req.url "/authorizations?client_id=#{client_id}&owner_id=#{owner_id}"
          end
          if resp.success?
            break
          else
            sleep current_backoff_seconds
            current_backoff_seconds = current_backoff_seconds * 2
          end
          return false if current_backoff_seconds > MAX_BACKOFF_SECONDS
        end
      end
    end
    true
  end

  # force_password_reset takes a list of Twitch user IDs and the LDAP account name
  # of the person requesting the job. It hits a Passport API with the user ID
  # list which in turn sets a property on the password table in Passport to
  # force the user to change their password upon the next successful login.
  # This method retries with exponential backoff, failing after a certain number
  # of retries.
  #
  # Upon successful deletion, this method emits audit logs to the History service
  # to indicate that the force password reset has taken place.
  #
  # Returns true if the request succeeds, false otherwise.
  def force_password_reset(users, reporter)
    connection = passport_connection
    body = JSON.dump({
        user_ids: users,
        token: Passport::Base.passport_token('123')
     })
    current_backoff_seconds = BASE_BACKOFF_SECONDS
    while current_backoff_seconds < MAX_BACKOFF_SECONDS
      resp = connection.post do |req|
        req.url "/bulk_force_password_reset/new"
        req.headers['Content-Type'] = "application/json"
        req.body = body
      end
      if resp.success?
        audit_password_action(users, reporter, PASSWORD_RESET_EVENT)
        return resp.success?
      end
      sleep current_backoff_seconds
      current_backoff_seconds = current_backoff_seconds * 2
    end
    false
  end

  # force_user_logout takes a list of Twitch user IDs and the LDAP account name
  # of the person requesting the job. It hits a Sessions Service API to bulk delete
  # sessions for the current batch of users. This method calls Sessions Service
  # in a loop until we have either errored too many times (with backoff) or
  # received a response saying that the current batch of session deletions has
  # completed.
  #
  # Upon successful deletion, this method emits audit logs to the History service
  # to indicate that the force logout has taken place.
  #
  # Returns true if the request succeeds, false otherwise.
  def force_user_logout(users, reporter)
    connection = sessions_connection
    body = JSON.dump({
        user_ids: users
     })

    current_backoff_seconds = BASE_BACKOFF_SECONDS

    # This loops a few times because we don't know how many times we need to
    # come back for the same batch. The sessions service provides feedback in
    # the response, which we leverage to determine when we are done.
    # Fail hard after a certain number of consecutive errors.
    loop do
      resp = connection.post do |req|
        req.url "/bulk_delete_sessions"
        req.headers['Content-Type'] = "application/json"
        req.body = body
      end
      resp_body = JSON.parse(resp.body)
      if resp.success?
        current_backoff_seconds = BASE_BACKOFF_SECONDS
        if resp_body['is_batch_finished']
          audit_session_delete(users, reporter)
          return true
        end
      else
        # Sleep to backoff if one of the requests fails. Fail the whole job if
        # we fail too many times here
        sleep current_backoff_seconds
        current_backoff_seconds = current_backoff_seconds * 2
        if current_backoff_seconds > 10
          return false
        end
      end
    end
  end

  def invalidate_facebook_connection(users)
    connection = connections_connection
    users.each do |user_id|
      current_backoff_seconds = BASE_BACKOFF_SECONDS
      while current_backoff_seconds < MAX_BACKOFF_SECONDS
        resp = connection.delete do |req|
          req.url "/admin/v2/facebook?user_id=#{user_id}"
        end
        if resp.success?
          break
        end
        sleep current_backoff_seconds
        current_backoff_seconds = current_backoff_seconds * 2
      end
    end
    true
  end

  def invalidate_sso(users)
    users.each do |user_id|
      SSO::Connection.destroy_all(user_id)
    end
    true
  end

  # audit_password_action takes a list of Twitch user IDs, the LDAP name of
  # the person requesting the job, and the audit log type and records an audit
  # of a password change event with the History service.
  def audit_password_action(users, reporter, audit_type)
    users.each do |user_id|
      change = History::ChangeSet.new(
        attribute: audit_type,
        old_value: 'false',
        new_value: 'true'
      )

      audit = History::Audit.new(
        action: AUDIT_ACTION_UPDATE,
        user_type: AUDIT_LDAP_USER,
        user_id:  reporter,
        resource_type: AUDIT_TWITCH_USER,
        resource_id: user_id,
        created_at: Time.now.utc.round(3).iso8601(3),
        expiry: AUDIT_DEFAULT_EXPIRY,
        changes: [change]
      )
      History::AddAudit.add(audit)
    end
  end

  # audit_session_delete takes a list of Twitch user IDs and the LDAP name of
  # the person requesting the job and records an audit of the force logout
  # with the History service.
  def audit_session_delete(users, reporter)
    users.each do |user_id|
      change = History::ChangeSet.new(
        attribute: 'all',
        old_value: 'false',
        new_value: 'true'
      )

      audit = History::Audit.new(
        action: AUDIT_ACTION_REVOKE_SESSION,
        user_type: AUDIT_LDAP_USER,
        user_id:  reporter,
        resource_type: AUDIT_TWITCH_USER,
        resource_id: user_id,
        created_at: Time.now.utc.round(3).iso8601(3),
        expiry: AUDIT_DEFAULT_EXPIRY,
        changes: [change]
      )
      History::AddAudit.add(audit)
    end
  end

  # audit_recovery_invalidation takes a list of Twitch user IDs and the LDAP name
  # of the person requesting the job and records an audit.
  def audit_recovery_invalidation(users, reporter)
    users.each do |user_id|
      change = History::ChangeSet.new(
        attribute: 'all',
        old_value: 'false',
        new_value: 'true'
      )

      audit = History::Audit.new(
        action: AUDIT_ACTION_RECOVERY_INVALIDATION,
        user_type: AUDIT_LDAP_USER,
        user_id:  reporter,
        resource_type: AUDIT_TWITCH_USER,
        resource_id: user_id,
        created_at: Time.now.utc.round(3).iso8601(3),
        expiry: AUDIT_DEFAULT_EXPIRY,
        invalidated: true,
        changes: [change]
      )
      History::AddAudit.add(audit)
    end
  end

  protected

  def fail_job(batch_id, message)
    set_status(error_message: "#{message} for batch #{batch_id}")
  end

  def faraday_connection(connection_options)
    connection = Faraday.new(connection_options) do |faraday|
      faraday.request :url_encoded
      faraday.adapter Faraday.default_adapter
    end
    connection.options.merge(
        timeout: TOTAL_TIMEOUT,
        open_timeout: OPEN_TIMEOUT
    )
    connection
  end

  def owl_connection
    connection_options = {
        url: Settings.owl.endpoint,
        request: {
            params_encoder: Faraday::NestedParamsEncoder,
        },
    }
    faraday_connection(connection_options)
  end

  def passport_connection
    connection_options = {
        url: Settings.passport.endpoint,
        request: {
            params_encoder: Faraday::NestedParamsEncoder,
        },
    }
    faraday_connection(connection_options)
  end

  def sessions_connection
    connection_options = {
        url: Settings.sessions.endpoint,
        request: {
            params_encoder: Faraday::NestedParamsEncoder,
        },
    }
    faraday_connection(connection_options)
  end

  def connections_connection
    connection_options = {
        url: Settings.connections.endpoint,
        request: {
            params_encoder: Faraday::NestedParamsEncoder,
        },
    }
    faraday_connection(connection_options)
  end

end
