require 'cloud_controller/process_observer'
require 'cloud_controller/database_uri_generator'
require 'cloud_controller/errors/application_missing'
require 'repositories/app_usage_event_repository'
require 'presenters/v3/cache_key_presenter'
require 'utils/uri_utils'
require 'models/runtime/helpers/package_state_calculator'
require 'models/helpers/process_types'
require 'models/helpers/health_check_types'
require 'cloud_controller/serializer'
require 'cloud_controller/integer_array_serializer'

require_relative 'buildpack'

module VCAP::CloudController
  class ProcessModel < Sequel::Model(:processes) # rubocop:disable Metrics/ClassLength
    include Serializer

    plugin :serialization
    plugin :after_initialize
    plugin :many_through_many

    extend IntegerArraySerializer

    def after_initialize
      self.instances        ||= db_schema[:instances][:default].to_i
      self.memory           ||= Config.config.get(:default_app_memory)
      self.disk_quota       ||= Config.config.get(:default_app_disk_in_mb)
      self.file_descriptors ||= Config.config.get(:instance_file_descriptor_limit)
      self.log_rate_limit   ||= Config.config.get(:default_app_log_rate_limit_in_bytes_per_second)
      self.metadata         ||= {}
    end

    NO_APP_PORT_SPECIFIED = -1
    DEFAULT_HTTP_PORT     = 8080
    DEFAULT_PORTS         = [DEFAULT_HTTP_PORT].freeze
    UNLIMITED_LOG_RATE = -1

    many_to_one :app, class: 'VCAP::CloudController::AppModel', key: :app_guid, primary_key: :guid, without_guid_generation: true
    many_to_one :revision, class: 'VCAP::CloudController::RevisionModel', key: :revision_guid, primary_key: :guid, without_guid_generation: true
    one_to_many :service_bindings, key: :app_guid, primary_key: :app_guid, without_guid_generation: true
    one_to_many :events, class: VCAP::CloudController::AppEvent, key: :app_id

    one_to_many :labels, class: 'VCAP::CloudController::ProcessLabelModel', key: :resource_guid, primary_key: :guid
    one_to_many :annotations, class: 'VCAP::CloudController::ProcessAnnotationModel', key: :resource_guid, primary_key: :guid

    one_through_one :space,
                    join_table: AppModel.table_name,
                    left_primary_key: :app_guid, left_key: :guid,
                    right_primary_key: :guid, right_key: :space_guid

    one_through_one :stack,
                    join_table: BuildpackLifecycleDataModel.table_name,
                    left_primary_key: :app_guid, left_key: :app_guid,
                    right_primary_key: :name, right_key: :stack,
                    after_load: :convert_nil_to_default_stack

    def convert_nil_to_default_stack(stack)
      associations[:stack] = Stack.default unless stack
    end

    one_through_one :latest_package,
                    class: 'VCAP::CloudController::PackageModel',
                    join_table: AppModel.table_name,
                    left_primary_key: :app_guid, left_key: :guid,
                    right_primary_key: :app_guid, right_key: :guid,
                    order: [Sequel.desc(:created_at), Sequel.desc(:id)], limit: 1

    one_through_one :latest_build,
                    class: 'VCAP::CloudController::BuildModel',
                    join_table: AppModel.table_name,
                    left_primary_key: :app_guid, left_key: :guid,
                    right_primary_key: :app_guid, right_key: :guid,
                    order: [Sequel.desc(:created_at), Sequel.desc(:id)], limit: 1

    one_through_one :latest_droplet,
                    class: 'VCAP::CloudController::DropletModel',
                    join_table: AppModel.table_name,
                    left_primary_key: :app_guid, left_key: :guid,
                    right_primary_key: :app_guid, right_key: :guid,
                    order: [Sequel.desc(:created_at), Sequel.desc(:id)], limit: 1

    one_through_one :desired_droplet,
                    class: '::VCAP::CloudController::DropletModel',
                    join_table: AppModel.table_name,
                    left_primary_key: :app_guid, left_key: :guid,
                    right_primary_key: :guid, right_key: :droplet_guid

    dataset_module do
      def staged
        association_join(:desired_droplet)
      end

      def runnable
        staged.where("#{ProcessModel.table_name}__state": STARTED).where { instances > 0 }
      end

      def diego
        where(diego: true)
      end

      def buildpack_type
        inner_join(BuildpackLifecycleDataModel.table_name, app_guid: :app_guid).
          select_all(:processes)
      end

      def non_docker_type
        inner_join(BuildpackLifecycleDataModel.table_name, app_guid: :app_guid).
          select_all(:processes)
      end
    end

    one_through_many :organization,
                     [
                       [ProcessModel.table_name, :id, :app_guid],
                       [AppModel.table_name, :guid, :space_guid],
                       %i[spaces guid organization_id]
                     ]

    many_to_many :routes,
                 join_table: RouteMappingModel.table_name,
                 left_primary_key: %i[app_guid type], left_key: %i[app_guid process_type],
                 right_primary_key: :guid, right_key: :route_guid,
                 distinct: true,
                 order: Sequel.asc(:id),
                 eager: :domain

    many_to_many :sidecars,
                 class: 'VCAP::CloudController::SidecarModel',
                 join_table: SidecarProcessTypeModel.table_name,
                 left_primary_key: %i[app_guid type], left_key: %i[app_guid type],
                 right_primary_key: :guid, right_key: :sidecar_guid,
                 distinct: true,
                 order: Sequel.asc(:id)

    one_to_many :route_mappings, class: 'VCAP::CloudController::RouteMappingModel', primary_key: %i[app_guid type], key: %i[app_guid process_type]

    add_association_dependencies events: :delete
    add_association_dependencies labels: :destroy
    add_association_dependencies annotations: :destroy

    export_attributes :name, :production, :space_guid, :stack_guid, :buildpack,
                      :detected_buildpack, :detected_buildpack_guid, :environment_json,
                      :memory, :instances, :disk_quota, :log_rate_limit, :state, :version,
                      :command, :console, :debug, :staging_task_id, :package_state,
                      :health_check_type, :health_check_timeout, :health_check_http_endpoint,
                      :staging_failed_reason, :staging_failed_description, :diego,
                      :docker_image, :package_updated_at, :detected_start_command, :enable_ssh,
                      :ports

    import_attributes :name, :production, :space_guid, :stack_guid, :buildpack,
                      :detected_buildpack, :environment_json, :memory, :instances, :disk_quota,
                      :log_rate_limit, :state, :command, :console, :debug, :staging_task_id,
                      :service_binding_guids, :route_guids, :health_check_type,
                      :health_check_http_endpoint, :health_check_timeout, :diego,
                      :docker_image, :app_guid, :enable_ssh, :ports

    serialize_attributes :json, :metadata
    serialize_attributes :integer_array, :ports

    STARTED            = 'STARTED'.freeze
    STOPPED            = 'STOPPED'.freeze
    APP_STATES         = [STARTED, STOPPED].freeze
    HEALTH_CHECK_TYPES = [
      HealthCheckTypes::PORT,
      HealthCheckTypes::PROCESS,
      HealthCheckTypes::HTTP,
      HealthCheckTypes::NONE
    ].freeze

    # Last staging response which will contain streaming log url
    attr_accessor :last_stager_response, :skip_process_observer_on_update, :skip_process_version_update

    alias_method :diego?, :diego

    def revisions_enabled?
      app.revisions_enabled
    end

    delegate :service_binding_k8s_enabled, to: :app
    delegate :file_based_vcap_services_enabled, to: :app

    def package_hash
      # this caches latest_package for performance reasons
      package = latest_package
      return nil if package.nil?

      if package.bits?
        package.checksum_info[:value]
      elsif package.docker?
        package.image
      end
    end

    def package_state
      calculator = PackageStateCalculator.new(self)
      calculator.calculate
    end

    def staging_task_id
      latest_build.try(:guid) || latest_droplet.try(:guid)
    end

    def droplet_hash
      desired_droplet.try(:droplet_hash)
    end

    def droplet_checksum
      desired_droplet.try(:checksum)
    end

    def actual_droplet
      return desired_droplet unless revisions_enabled?

      revision&.droplet || desired_droplet
    end

    def environment_json
      return app.environment_variables unless revisions_enabled?

      revision&.environment_variables || app.environment_variables
    end

    def package_updated_at
      latest_package.try(:created_at)
    end

    def docker_image
      latest_package.try(:image)
    end

    def docker_username
      latest_package.try(:docker_username)
    end

    def docker_password
      latest_package.try(:docker_password)
    end

    def copy_buildpack_errors
      return unless app&.lifecycle_data
      return if app.lifecycle_data.valid?

      app.lifecycle_data.errors.each_value do |errs|
        errs.each do |err|
          errors.add(:buildpack, err)
        end
      end
    end

    def validation_policies
      [
        MaxDiskQuotaPolicy.new(self, max_app_disk_in_mb),
        MinDiskQuotaPolicy.new(self),
        MinMemoryPolicy.new(self),
        AppMaxMemoryPolicy.new(self, space, :space_quota_exceeded),
        AppMaxMemoryPolicy.new(self, organization, :quota_exceeded),
        AppMaxInstanceMemoryPolicy.new(self, organization, :instance_memory_limit_exceeded),
        AppMaxInstanceMemoryPolicy.new(self, space, :space_instance_memory_limit_exceeded),
        InstancesPolicy.new(self),
        MaxAppInstancesPolicy.new(self, organization, organization && organization.quota_definition, :app_instance_limit_exceeded),
        MaxAppInstancesPolicy.new(self, space, space && space.space_quota_definition, :space_app_instance_limit_exceeded),
        MinLogRateLimitPolicy.new(self),
        AppMaxLogRateLimitPolicy.new(self, space, 'exceeds space log rate quota'),
        AppMaxLogRateLimitPolicy.new(self, organization, 'exceeds organization log rate quota'),
        HealthCheckPolicy.new(self, health_check_timeout, health_check_invocation_timeout, health_check_type, health_check_http_endpoint, health_check_interval),
        ReadinessHealthCheckPolicy.new(self, readiness_health_check_invocation_timeout, readiness_health_check_type, readiness_health_check_http_endpoint,
                                       readiness_health_check_interval),
        DockerPolicy.new(self),
        PortsPolicy.new(self),
        ProcessUserPolicy.new(self, permitted_users)
      ]
    end

    def validate
      validates_presence :app

      copy_buildpack_errors

      validates_includes APP_STATES, :state, allow_missing: true, message: 'must be one of ' + APP_STATES.join(', ')

      validation_policies.map(&:validate)
      validate_sidecar_memory if modified?(:memory)
    end

    def validate_sidecar_memory
      return if SidecarMemoryLessThanProcessMemoryPolicy.new([self]).valid?

      errors.add(:memory, :process_memory_insufficient_for_sidecars)
    end

    def before_create
      set_new_version
      super
    end

    def after_create
      super
      create_app_usage_event
    end

    def after_update
      super
      create_app_usage_event
    end

    def before_validation
      # This is in before_validation because we need to validate ports based on diego flag
      self.diego = true if diego.nil?

      # column_changed?(:ports) reports false here for reasons unknown
      @ports_changed_by_user = changed_columns.include?(:ports)
      super
    end

    def before_save
      set_new_version if version_needs_to_be_updated?
      super
    end

    # rubocop:disable Metrics/CyclomaticComplexity
    def version_needs_to_be_updated?
      # change version if:
      #
      # * transitioning to STARTED
      # * memory is changed
      # * health check type is changed
      # * health check http endpoint is changed
      # * readiness health check type is changed
      # * readiness health check http endpoint is changed
      # * ports were changed by the user
      #
      # this is to indicate that the running state of an application has changed,
      # and that the system should converge on this new version.

      started? &&
        (column_changed?(:state) ||
        (column_changed?(:memory) && !skip_process_version_update) ||
        (column_changed?(:health_check_type) && !skip_process_version_update) ||
        (column_changed?(:readiness_health_check_type) && !skip_process_version_update) ||
        (column_changed?(:health_check_http_endpoint) && !skip_process_version_update) ||
        (column_changed?(:readiness_health_check_http_endpoint) && !skip_process_version_update) ||
        (@ports_changed_by_user && !skip_process_version_update)
        )
    end
    # rubocop:enable Metrics/CyclomaticComplexity

    delegate :enable_ssh, to: :app

    def set_new_version
      self.version = SecureRandom.uuid
    end

    def needs_package_in_current_state?
      started?
    end

    delegate :in_suspended_org?, to: :space

    def being_started?
      column_changed?(:state) && started?
    end

    def being_stopped?
      column_changed?(:state) && stopped?
    end

    def desired_instances
      started? ? instances : 0
    end

    def before_destroy
      lock!
      self.state = 'STOPPED'
      super
    end

    def after_destroy
      super
      create_app_usage_event
      db.after_commit { ProcessObserver.deleted(self) }
    end

    def execution_metadata
      desired_droplet.try(:execution_metadata) || ''
    end

    def started_command
      return specified_or_detected_command if !revisions_enabled? || revision.nil?

      specified_commands = revision.commands_by_process_type
      specified_commands[type] || revision.droplet&.process_start_command(type) || ''
    end

    def run_action_user
      return user if user.present?

      docker? ? docker_run_action_user : AppModel::DEFAULT_CONTAINER_USER
    end

    def specified_or_detected_command
      command.presence || detected_start_command
    end

    def detected_start_command
      desired_droplet&.process_start_command(type) || ''
    end

    def detected_buildpack_guid
      desired_droplet.try(:buildpack_receipt_buildpack_guid)
    end

    def detected_buildpack_name
      desired_droplet.try(:buildpack_receipt_buildpack)
    end

    def detected_buildpack
      desired_droplet.try(:buildpack_receipt_detect_output)
    end

    def staging_failed_reason
      latest_build.try(:error_id) || latest_droplet.try(:error_id)
    end

    def staging_failed_description
      latest_build.try(:error_description) || latest_droplet.try(:error_description)
    end

    def console=(value)
      self.metadata ||= {}
      self.metadata['console'] = value
    end

    def console
      # without the == true check, this expression can return nil if
      # the key doesn't exist, rather than false
      self.metadata && self.metadata['console'] == true
    end

    def debug=(value)
      self.metadata ||= {}
      # We don't support sending nil through API
      self.metadata['debug'] = value == 'none' ? nil : value
    end

    def debug
      self.metadata && self.metadata['debug']
    end

    delegate :name, to: :app

    delegate :docker?, to: :app

    delegate :cnb?, to: :app

    delegate :windows_gmsa_credential_refs, to: :app

    def database_uri
      service_binding_uris = service_bindings.map do |binding|
        binding.credentials['uri'] if binding.credentials.present?
      end.compact
      DatabaseUriGenerator.new(service_binding_uris).database_uri
    end

    def max_app_disk_in_mb
      VCAP::CloudController::Config.config.get(:maximum_app_disk_in_mb)
    end

    def self.user_visibility_filter(user)
      space_guids = Space.join(:spaces_developers, space_id: :id, user_id: user.id).select(:spaces__guid).
                    union(Space.join(:spaces_managers, space_id: :id, user_id: user.id).select(:spaces__guid)).
                    union(Space.join(:spaces_auditors, space_id: :id, user_id: user.id).select(:spaces__guid)).
                    union(Space.join(:organizations_managers, organization_id: :organization_id, user_id: user.id).select(:spaces__guid)).select(:guid)

      {
        "#{ProcessModel.table_name}__app_guid": AppModel.where(space: space_guids.all).select(:guid)
      }
    end

    def needs_staging?
      package_hash.present? && !staged? && started?
    end

    def staged?
      package_state == 'STAGED'
    end

    def staging_failed?
      package_state == 'FAILED'
    end

    def pending?
      package_state == 'PENDING'
    end

    def staging?
      pending? && latest_build.present? && latest_build.staging?
    end

    def started?
      state == STARTED
    end

    def package_available?
      desired_droplet || latest_package.try(:ready?)
    end

    def active?
      return false if docker? && !FeatureFlag.enabled?(:diego_docker)
      return false if cnb? && !FeatureFlag.enabled?(:diego_cnb)

      true
    end

    def stopped?
      state == STOPPED
    end

    def uris
      routes.map(&:uri)
    end

    def buildpack
      app.lifecycle_data.buildpack_models.first
    end

    def buildpack_specified?
      app.lifecycle_data.buildpacks.any?
    end

    def custom_buildpack_url
      app.lifecycle_data.first_custom_buildpack_url
    end

    def after_save
      super

      db.after_commit { ProcessObserver.updated(self) unless skip_process_observer_on_update }
    end

    def to_hash(opts={})
      opts[:redact] = (%w[environment_json system_env_json] unless VCAP::CloudController::Security::AccessContext.new.can?(:read_env, self))
      super
    end

    def web?
      type == ProcessTypes::WEB
    end

    def docker_ports
      return desired_droplet.docker_ports if desired_droplet.present? && desired_droplet.staged?

      []
    end

    def open_ports
      open_ports = ports || []

      if docker?
        has_mapping_without_port = route_mappings_dataset.where(app_port: ProcessModel::NO_APP_PORT_SPECIFIED).any?
        needs_docker_ports = docker_ports.present? && (has_mapping_without_port || open_ports.empty?)

        open_ports += docker_ports if needs_docker_ports

        open_ports += DEFAULT_PORTS if docker_ports.blank? && has_mapping_without_port
      end

      open_ports += DEFAULT_PORTS if web? && open_ports.empty?
      open_ports.uniq
    end

    private

    def permitted_users
      Set.new([AppModel::DEFAULT_CONTAINER_USER]) + Config.config.get(:additional_allowed_process_users)
    end

    def docker_run_action_user
      desired_droplet&.docker_user.presence || AppModel::DEFAULT_DOCKER_CONTAINER_USER
    end

    def non_unique_process_types
      return [] unless app

      @non_unique_process_types ||= app.processes_dataset.select_map(:type).select do |process_type|
        process_type.downcase == type.downcase
      end
    end

    def changed_from_default_ports?
      @ports_changed_by_user && (initial_value(:ports).nil? || initial_value(:ports) == [DEFAULT_HTTP_PORT])
    end

    def metadata_deserialized
      deserialized_values[:metadata]
    end

    def app_usage_event_repository
      @app_usage_event_repository ||= Repositories::AppUsageEventRepository.new
    end

    def create_app_usage_event
      return unless app_usage_changed?

      app_usage_event_repository.create_from_process(self)
    end

    def app_usage_changed?
      previously_started = initial_value(:state) == STARTED
      return true if previously_started != started?
      return true if started? && footprint_changed?

      false
    end

    def footprint_changed?
      column_changed?(:production) || column_changed?(:memory) ||
        column_changed?(:instances)
    end

    class << self
      def logger
        @logger ||= Steno.logger('cc.models.app')
      end
    end
  end
end
