# frozen_string_literal: true

module Ci
  # This class is a collection of common features between Ci::Build and Ci::Bridge.
  # In https://gitlab.com/groups/gitlab-org/-/epics/9991, we aim to clarify class naming conventions.
  class Processable < ::CommitStatus
    include Gitlab::Utils::StrongMemoize
    include FromUnion
    include Ci::Metadatable
    extend ::Gitlab::Utils::Override

    ACTIONABLE_WHEN = %w[manual delayed].freeze

    self.allow_legacy_sti_class = true

    attribute :temp_job_definition

    has_one :resource, class_name: 'Ci::Resource', foreign_key: 'build_id', inverse_of: :processable
    has_one :sourced_pipeline, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id, inverse_of: :source_job
    has_one :trigger, through: :pipeline
    has_one :job_environment, class_name: 'Environments::Job', inverse_of: :job
    has_one :job_definition_instance, ->(job) { in_partition(job) },
      class_name: 'Ci::JobDefinitionInstance',
      foreign_key: :job_id,
      partition_foreign_key: :partition_id,
      inverse_of: :job,
      autosave: true

    has_one :job_definition, ->(job) { in_partition(job) },
      class_name: 'Ci::JobDefinition',
      foreign_key: :job_id,
      partition_foreign_key: :partition_id,
      through: :job_definition_instance

    has_many :job_messages,
      ->(build) { in_partition(build) },
      class_name: 'Ci::JobMessage',
      foreign_key: :job_id,
      inverse_of: :job,
      partition_foreign_key: :partition_id

    has_many :error_job_messages,
      ->(build) { in_partition(build).error.order(:id) },
      class_name: 'Ci::JobMessage',
      foreign_key: :job_id,
      inverse_of: :job,
      partition_foreign_key: :partition_id

    belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :processables

    accepts_nested_attributes_for :needs
    accepts_nested_attributes_for :job_definition_instance

    scope :preload_needs, -> { preload(:needs) }
    scope :preload_job_definition_instances, -> { preload(:job_definition_instance) }
    scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }

    scope :with_needs, ->(names = nil) do
      needs = Ci::BuildNeed.scoped_build.select(1)
      needs = needs.where(name: names) if names
      where('EXISTS (?)', needs)
    end

    scope :without_needs, ->(names = nil) do
      needs = Ci::BuildNeed.scoped_build.select(1)
      needs = needs.where(name: names) if names
      where('NOT EXISTS (?)', needs)
    end

    scope :with_interruptible_true, -> do
      where_exists(
        Ci::JobDefinitionInstance
          .joins(:job_definition)
          .scoped_job
          .merge(Ci::JobDefinition.with_interruptible_true)
      )
    end

    scope :with_metadata_interruptible_false, -> do
      joins(:metadata).where.not(
        Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) }
      )
    end

    scope :with_interruptible_false, -> do
      where_not_exists(
        Ci::JobDefinitionInstance
           .joins(:job_definition)
           .scoped_job
           .merge(Ci::JobDefinition.with_interruptible_true)
      )
    end

    # The run after commit queue is processed LIFO
    # We need to ensure that the Redis data is persisted before any other callbacks the might depend on it.
    before_commit do |job|
      job.run_after_commit do
        redis_state.save if defined?(@redis_state)
      end
    end

    state_machine :status do
      event :enqueue do
        transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :with_resource_group?
      end

      event :enqueue_scheduled do
        transition scheduled: :waiting_for_resource, if: :with_resource_group?
      end

      event :enqueue_waiting_for_resource do
        transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites?
        transition waiting_for_resource: :pending
      end

      before_transition any => :waiting_for_resource do |processable|
        processable.waiting_for_resource_at = Time.current
      end

      before_transition on: :enqueue_waiting_for_resource do |processable|
        next unless processable.with_resource_group?

        processable.resource_group.assign_resource_to(processable)
      end

      after_transition any => :waiting_for_resource do |processable|
        processable.run_after_commit do
          assign_resource_from_resource_group(processable)
        end
      end

      after_transition any => ::Ci::Processable.completed_statuses do |processable|
        next unless processable.with_resource_group?

        processable.resource_group.release_resource_from(processable)

        processable.run_after_commit do
          assign_resource_from_resource_group(processable)
        end
      end

      after_transition any => [:failed] do |processable|
        next if processable.allow_failure?
        next unless processable.can_auto_cancel_pipeline_on_job_failure?

        processable.run_after_commit do
          processable.pipeline.cancel_async_on_job_failure
        end
      end
    end

    def self.fabricate(attrs)
      new(attrs).tap do |build|
        job_definition = ::Ci::JobDefinition.fabricate(
          config: attrs,
          project_id: build.project_id,
          partition_id: build.partition_id
        )

        build.temp_job_definition = job_definition
      end
    end

    def self.select_with_aggregated_needs(project)
      aggregated_needs_names = Ci::BuildNeed
        .scoped_build
        .select("ARRAY_AGG(name)")
        .to_sql

      all.select(
        '*',
        "(#{aggregated_needs_names}) as aggregated_needs_names"
      )
    end

    # Old processables may have scheduling_type as nil,
    # so we need to ensure the data exists before using it.
    def self.populate_scheduling_type!
      needs = Ci::BuildNeed.scoped_build.select(1)
      where(scheduling_type: nil).update_all(
        "scheduling_type = CASE WHEN (EXISTS (#{needs.to_sql}))
         THEN #{scheduling_types[:dag]}
         ELSE #{scheduling_types[:stage]}
         END"
      )
    end

    def assign_resource_from_resource_group(processable)
      Ci::ResourceGroups::AssignResourceFromResourceGroupWorker.perform_async(processable.resource_group_id)
    end

    validates :type, presence: true
    validates :scheduling_type, presence: true, on: :create, unless: :importing?

    delegate :merge_request?,
      :merge_request_ref?,
      :legacy_detached_merge_request_pipeline?,
      :merge_train_pipeline?,
      to: :pipeline

    delegate :short_token, to: :trigger, prefix: true, allow_nil: true

    # Scoped user is present when the user creating the pipeline supports composite identity.
    # For example: a service account like GitLab Duo. The scoped user is used to further restrict
    # the permissions of the CI job token associated to the `job.user`.
    def scoped_user
      # If jobs are retried by human users (not composite identity) we want to
      # ignore the persisted `scoped_user_id`, because that is propagated
      # together with `options` to cloned jobs.
      # We also handle the case where `user` is `nil` (legacy behavior in specs).
      return unless user&.composite_identity_enforced?

      User.find_by_id(scoped_user_id)
    end
    strong_memoize_attr :scoped_user

    def retryable?
      return false if retried? || archived? || deployment_rejected?

      success? || failed? || canceled? || canceling?
    end

    def archived?(...)
      degenerated? || super
    end

    def aggregated_needs_names
      read_attribute(:aggregated_needs_names)
    end

    def schedulable?
      raise NotImplementedError
    end

    def action?
      raise NotImplementedError
    end

    def can_auto_cancel_pipeline_on_job_failure?
      raise NotImplementedError
    end

    def other_manual_actions
      pipeline.manual_actions.reject { |action| action.name == name }
    end

    def when
      read_attribute(:when) || 'on_success'
    end

    def expanded_environment_name
      raise NotImplementedError
    end

    def persisted_environment
      raise NotImplementedError
    end

    override :all_met_to_become_pending?
    def all_met_to_become_pending?
      super && !with_resource_group?
    end

    def with_resource_group?
      self.resource_group_id.present?
    end

    # Overriding scheduling_type enum's method for nil `scheduling_type`s
    def scheduling_type_dag?
      scheduling_type.nil? ? find_legacy_scheduling_type == :dag : super
    end

    # scheduling_type column of previous builds/bridges have not been populated,
    # so we calculate this value on runtime when we need it.
    def find_legacy_scheduling_type
      strong_memoize(:find_legacy_scheduling_type) do
        needs.exists? ? :dag : :stage
      end
    end

    def needs_attributes
      strong_memoize(:needs_attributes) do
        needs.map { |need| need.attributes.except('id', 'build_id') }
      end
    end

    def ensure_scheduling_type!
      # If this has a scheduling_type, it means all processables in the pipeline already have.
      return if scheduling_type

      pipeline.ensure_scheduling_type!
      reset
    end

    def dependency_variables
      return [] if all_dependencies.empty?

      dependencies_with_accessible_artifacts = job_dependencies_with_accessible_artifacts(all_dependencies)

      Gitlab::Ci::Variables::Collection.new.concat(
        Ci::JobVariable.where(job: dependencies_with_accessible_artifacts).dotenv_source
      )
    end

    def job_dependencies_with_accessible_artifacts(all_dependencies)
      build_ids = all_dependencies.collect(&:id)

      Ci::Build.id_in(build_ids).builds_with_accessible_artifacts(self.project_id)
    end

    def all_dependencies
      strong_memoize(:all_dependencies) do
        dependencies.all
      end
    end

    def manual_job?
      self.when == 'manual'
    end

    def manual_confirmation_message
      options[:manual_confirmation] if manual_job? && playable?
    end

    def redis_state
      strong_memoize(:redis_state) do
        Ci::JobRedisState.find_or_initialize_by(job: self)
      end
    end

    def enqueue_immediately?
      redis_state.enqueue_immediately?
    end

    def set_enqueue_immediately!
      redis_state.enqueue_immediately = true
    end

    private

    def dependencies
      strong_memoize(:dependencies) do
        Ci::BuildDependencies.new(self)
      end
    end
  end
end
