# frozen_string_literal: true

class RemoteMirror < ApplicationRecord
  include AfterCommitQueue
  include MirrorAuthentication

  MAX_FIRST_RUNTIME = 3.hours
  MAX_INCREMENTAL_RUNTIME = 1.hour
  PROTECTED_BACKOFF_DELAY   = 1.minute
  UNPROTECTED_BACKOFF_DELAY = 5.minutes

  attr_encrypted :credentials,
                 key: Settings.attr_encrypted_db_key_base,
                 marshal: true,
                 encode: true,
                 mode: :per_attribute_iv_and_salt,
                 insecure_mode: true,
                 algorithm: 'aes-256-cbc'

  belongs_to :project, inverse_of: :remote_mirrors

  validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true }

  before_save :set_new_remote_name, if: :mirror_url_changed?

  after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available }
  after_save :refresh_remote, if: :saved_change_to_mirror_url?
  after_update :reset_fields, if: :saved_change_to_mirror_url?

  after_commit :remove_remote, on: :destroy

  before_validation :store_credentials

  scope :enabled, -> { where(enabled: true) }
  scope :started, -> { with_update_status(:started) }

  scope :stuck, -> do
    started
      .where('(last_update_started_at < ? AND last_update_at IS NOT NULL)',
             MAX_INCREMENTAL_RUNTIME.ago)
      .or(where('(last_update_started_at < ? AND last_update_at IS NULL)',
                MAX_FIRST_RUNTIME.ago))
  end

  state_machine :update_status, initial: :none do
    event :update_start do
      transition any => :started
    end

    event :update_finish do
      transition started: :finished
    end

    event :update_fail do
      transition started: :failed
    end

    event :update_retry do
      transition started: :to_retry
    end

    state :started
    state :finished
    state :failed
    state :to_retry

    after_transition any => :started do |remote_mirror, _|
      Gitlab::Metrics.add_event(:remote_mirrors_running)

      remote_mirror.update(last_update_started_at: Time.now)
    end

    after_transition started: :finished do |remote_mirror, _|
      Gitlab::Metrics.add_event(:remote_mirrors_finished)

      timestamp = Time.now
      remote_mirror.update!(
        last_update_at: timestamp,
        last_successful_update_at: timestamp,
        last_error: nil,
        error_notification_sent: false
      )
    end

    after_transition started: :failed do |remote_mirror|
      Gitlab::Metrics.add_event(:remote_mirrors_failed)

      remote_mirror.update(last_update_at: Time.now)

      remote_mirror.run_after_commit do
        RemoteMirrorNotificationWorker.perform_async(remote_mirror.id)
      end
    end
  end

  def remote_name
    super || fallback_remote_name
  end

  def update_failed?
    update_status == 'failed'
  end

  def update_in_progress?
    update_status == 'started'
  end

  def update_repository(options)
    if ssh_mirror_url?
      if ssh_key_auth? && ssh_private_key.present?
        options[:ssh_key] = ssh_private_key
      end

      if ssh_known_hosts.present?
        options[:known_hosts] = ssh_known_hosts
      end
    end

    Gitlab::Git::RemoteMirror.new(
      project.repository.raw,
      remote_name,
      **options
    ).update
  end

  def sync?
    enabled?
  end

  def sync
    return unless sync?

    if recently_scheduled?
      RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.now)
    else
      RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.now)
    end
  end

  def enabled
    return false unless project && super
    return false unless project.remote_mirror_available?
    return false unless project.repository_exists?
    return false if project.pending_delete?

    true
  end
  alias_method :enabled?, :enabled

  def disabled?
    !enabled?
  end

  def updated_since?(timestamp)
    return false if failed?

    last_update_started_at && last_update_started_at > timestamp
  end

  def mark_for_delete_if_blank_url
    mark_for_destruction if url.blank?
  end

  def update_error_message(error_message)
    self.last_error = Gitlab::UrlSanitizer.sanitize(error_message)
  end

  def mark_for_retry!(error_message)
    update_error_message(error_message)
    update_retry!
  end

  def mark_as_failed!(error_message)
    update_error_message(error_message)
    update_fail!
  end

  def url=(value)
    super(value) && return unless Gitlab::UrlSanitizer.valid?(value)

    mirror_url = Gitlab::UrlSanitizer.new(value)
    self.credentials ||= {}
    self.credentials = self.credentials.merge(mirror_url.credentials)

    super(mirror_url.sanitized_url)
  end

  def url
    if super
      Gitlab::UrlSanitizer.new(super, credentials: credentials).full_url
    end
  rescue
    super
  end

  def safe_url
    return if url.nil?

    result = URI.parse(url)
    result.password = '*****' if result.password
    result.user = '*****' if result.user && result.user != 'git' # tokens or other data may be saved as user
    result.to_s
  rescue URI::Error
  end

  def ensure_remote!
    return unless project
    return unless remote_name && remote_url

    # If this fails or the remote already exists, we won't know due to
    # https://gitlab.com/gitlab-org/gitaly/issues/1317
    project.repository.add_remote(remote_name, remote_url)
  end

  def after_sent_notification
    update_column(:error_notification_sent, true)
  end

  def backoff_delay
    if self.only_protected_branches
      PROTECTED_BACKOFF_DELAY
    else
      UNPROTECTED_BACKOFF_DELAY
    end
  end

  def max_runtime
    last_update_at.present? ? MAX_INCREMENTAL_RUNTIME : MAX_FIRST_RUNTIME
  end

  private

  def store_credentials
    # This is a necessary workaround for attr_encrypted, which doesn't otherwise
    # notice that the credentials have changed
    self.credentials = self.credentials
  end

  # The remote URL omits any password if SSH public-key authentication is in use
  def remote_url
    return url unless ssh_key_auth? && password.present?

    Gitlab::UrlSanitizer.new(read_attribute(:url), credentials: { user: user }).full_url
  rescue
    super
  end

  def fallback_remote_name
    return unless id

    "remote_mirror_#{id}"
  end

  def recently_scheduled?
    return false unless self.last_update_started_at

    self.last_update_started_at >= Time.now - backoff_delay
  end

  def reset_fields
    update_columns(
      last_error: nil,
      last_update_at: nil,
      last_successful_update_at: nil,
      update_status: 'finished',
      error_notification_sent: false
    )
  end

  def set_override_remote_mirror_available
    enabled = read_attribute(:enabled)

    project.update(remote_mirror_available_overridden: enabled)
  end

  def set_new_remote_name
    self.remote_name = "remote_mirror_#{SecureRandom.hex}"
  end

  def refresh_remote
    return unless project

    # Before adding a new remote we have to delete the data from
    # the previous remote name
    prev_remote_name = remote_name_before_last_save || fallback_remote_name
    run_after_commit do
      project.repository.async_remove_remote(prev_remote_name)
    end

    project.repository.add_remote(remote_name, remote_url)
  end

  def remove_remote
    return unless project # could be pending to delete so don't need to touch the git repository

    project.repository.async_remove_remote(remote_name)
  end

  def mirror_url_changed?
    url_changed? || credentials_changed?
  end

  def saved_change_to_mirror_url?
    saved_change_to_url? || saved_change_to_credentials?
  end
end
