class StudyFile

  ###
  #
  # StudyFile: class holding metadata about data files either uploaded through the UI or 'synced' from a GCS bucket
  #
  ###

  ###
  #
  # SETTINGS & FIELD DEFINITIONS
  #
  ###

  include Mongoid::Document
  include Mongoid::Timestamps
  include Rails.application.routes.url_helpers # for accessing download_file_path and download_private_file_path
  include Swagger::Blocks
  include Mongoid::History::Trackable

  # carrierwave settings
  mount_uploader :upload, UploadUploader, mount_on: :upload_file_name

  # constants, used for statuses and file types
  STUDY_FILE_TYPES = ['Cluster', 'Coordinate Labels', 'Expression Matrix', 'MM Coordinate Matrix', '10X Genes File',
                      '10X Barcodes File', 'Gene List', 'Metadata',
                      'Fastq', 'BAM', 'BAM Index', 'BED', 'Tab Index',
                      'Documentation', 'Other', 'Analysis Output', 'Ideogram Annotations', 'AnnData', 'Seurat',
                      'Differential Expression'].freeze
  CUSTOM_FILE_TYPE_NAMES = {
    'MM Coordinate Matrix' => 'Sparse matrix (.mtx)',
    'Expression Matrix' => 'Dense matrix',
    '10X Genes File' => '10X Features File'
  }.freeze
  PARSEABLE_TYPES = ['Cluster', 'Coordinate Labels', 'Expression Matrix', 'MM Coordinate Matrix', '10X Genes File',
                     '10X Barcodes File', 'Gene List', 'Metadata', 'Analysis Output', 'AnnData',
                     'Differential Expression'].freeze
  DISALLOWED_SYNC_TYPES = ['Differential Expression'].freeze
  ALLOWED_SYNC_TYPES = STUDY_FILE_TYPES - DISALLOWED_SYNC_TYPES
  STUDY_FILE_TYPE_NAME_HASH = ALLOWED_SYNC_TYPES.zip(ALLOWED_SYNC_TYPES).to_h.merge(CUSTOM_FILE_TYPE_NAMES).freeze
  UPLOAD_STATUSES = %w(new uploading uploaded).freeze
  PARSE_STATUSES = %w(unparsed parsing parsed failed).freeze
  PRIMARY_DATA_EXTENTIONS = %w(
    fastq fastq.zip fastq.gz fastq.tar.gz fq fq.zip fq.gz fq.tar.gz bam bam.gz bam.bai bam.gz.bai
  ).freeze
  PRIMARY_DATA_TYPES = ['Fastq', 'BAM', 'BAM Index'].freeze
  TAXON_REQUIRED_TYPES = [
    'Fastq', 'BAM', 'BED', 'Expression Matrix', 'MM Coordinate Matrix', 'Ideogram Annotations'
  ].freeze
  ASSEMBLY_REQUIRED_TYPES = ['BAM', 'BED', 'Ideogram Annotations'].freeze
  GZIP_MAGIC_NUMBER = "\x1f\x8b".force_encoding(Encoding::ASCII_8BIT).freeze
  REQUIRED_ATTRIBUTES = %w(file_type name).freeze
  # allowed bulk download file types
  # 'Expression' covers dense & sparse matrix files
  # 'None' is used when only bulk downloading a single directory_listing folder
  BULK_DOWNLOAD_TYPES = ['Expression', 'Metadata', 'Cluster', 'Coordinate Labels', 'Fastq', 'BAM', 'BED', 'Documentation',
                         'Other', 'Analysis Output', 'Ideogram Annotations', 'None'].freeze

  # Constants for scoping values for AnalysisParameter inputs/outputs
  ASSOCIATED_MODEL_METHOD = %w(gs_url name upload_file_name bucket_location).freeze
  ASSOCIATED_MODEL_DISPLAY_METHOD = %w(name upload_file_name bucket_location).freeze
  OUTPUT_ASSOCIATION_ATTRIBUTE = %w(taxon_id genome_assembly_id study_file_bundle_id).freeze
  ANALYSIS_PARAMETER_FILTERS = {
      'file_type' => STUDY_FILE_TYPES.dup,
      'taxon_id' => Taxon.all.map {|t| [t.common_name, t.id.to_s]}
  }.freeze

  # associations
  belongs_to :study, index: true
  has_many :cluster_groups, dependent: :destroy
  has_many :genes, dependent: :destroy
  has_many :precomputed_scores, dependent: :destroy
  has_many :cell_metadata, dependent: :destroy
  has_many :differential_expression_results, dependent: :destroy
  belongs_to :taxon, optional: true
  belongs_to :genome_assembly, optional: true
  belongs_to :genome_annotation, optional: true
  belongs_to :study_file_bundle, optional: true
  embeds_one :expression_file_info
  embeds_one :cluster_file_info, cascade_callbacks: true
  embeds_one :heatmap_file_info
  embeds_one :ann_data_file_info
  embeds_one :differential_expression_file_info

  accepts_nested_attributes_for :expression_file_info
  accepts_nested_attributes_for :cluster_file_info
  accepts_nested_attributes_for :heatmap_file_info
  accepts_nested_attributes_for :ann_data_file_info
  accepts_nested_attributes_for :differential_expression_file_info
  validate :show_nested_info_errors

  # field definitions
  field :name, type: String
  field :description, type: String
  field :file_type, type: String
  field :status, type: String
  field :parse_status, type: String, default: 'unparsed'
  field :data_dir, type: String
  field :human_fastq_url, type: String
  field :human_data, type: Boolean, default: false
  field :use_metadata_convention, type: Boolean, default: false
  field :generation, type: String
  field :x_axis_label, type: String, default: ''
  field :y_axis_label, type: String, default: ''
  field :z_axis_label, type: String, default: ''
  field :x_axis_min, type: Integer
  field :x_axis_max, type: Integer
  field :y_axis_min, type: Integer
  field :y_axis_max, type: Integer
  field :z_axis_min, type: Integer
  field :z_axis_max, type: Integer
  field :is_spatial, type: Boolean, default: false

  # Hyperlink to an external web resource for a cluster or externally hosted file
  # Set in upload / sync UI.  Shown in Study Overview page. Also used in ImportService
  field :external_link_url, type: String
  field :external_link_title, type: String # Link text
  field :external_link_description, type: String # Link tooltip
  field :external_identifier, type: String # for tracking external files from ImportService
  field :imported_from, type: String # Human-readable tag for external service that study was imported from, e.g. HCA

  # for spatial files, the ids of cluster files that correspond to this file for default display
  field :spatial_cluster_associations, type: Array, default: []
  field :queued_for_deletion, type: Boolean, default: false
  field :remote_location, type: String, default: ''
  field :options, type: Hash, default: {}
  # legacy attributes from Paperclip for Carrierwave compatibility
  field :upload_file_size, type: Integer
  field :upload_content_type, type: String

  ##
  #
  # SWAGGER DEFINITIONS
  #
  ##

  swagger_schema :StudyFile do
    key :required, [:file_type, :name]
    key :name, 'StudyFile'
    property :id do
      key :type, :string
    end
    property :study_id do
      key :type, :string
      key :description, 'ID of Study to which StudyFile belongs'
    end
    property :taxon_id do
      key :type, :string
      key :description, 'Database ID of Taxon entry (species) to which StudyFile belongs, if required/present.  THIS IS NOT THE NCBI TAXON ID.'
    end
    property :species do
      key :type, :string
      key :description, '(optional) Common name of a species registered in the portal to set taxon_id association manually'
    end
    property :genome_assembly_id do
      key :type, :string
      key :description, 'ID of GenomeAssembly to which StudyFile belongs, if required/present'
    end
    property :assembly do
      key :type, :string
      key :description, '(optional) Common name of a genome assembly registered in the portal to set genome_assembly_id association manually'
    end
    property :study_file_bundle_id do
      key :type, :string
      key :description, 'ID of StudyFileBundle to which StudyFile belongs, if present'
    end
    property :name do
      key :type, :string
      key :description, 'Name of StudyFile (either filename or name of Cluster/Gene List)'
    end
    property :description do
      key :type, :string
      key :description, 'StudyFile description, used in download views'
    end
    property :upload do
      key :type, :string
      key :format, :binary
      key :description, 'File object that StudyFile represents (will auto-set all upload_* attributes from File headers)'
    end
    property :upload_file_name do
      key :type, :string
      key :description, 'Filename of upload File object'
    end
    property :upload_content_type do
      key :type, :string
      key :description, 'Content-Type of upload File object'
    end
    property :upload_file_size do
      key :type, :integer
      key :description, 'Size (in bytes) of upload File object'
    end
    property :upload_fingerprint do
      key :type, :string
      key :description, 'Checksum of upload File object'
    end
    property :upload_updated_at do
      key :type, :string
      key :format, :date_time
      key :description, 'Last update timestamp of upload File object'
    end
    property :file_type do
      key :type, :string
      key :enum, STUDY_FILE_TYPES
      key :description, 'Type of file, governs parsing/caching behavior'
    end
    property :status do
      key :type, :string
      key :enum, UPLOAD_STATUSES
      key :description, 'Status of File object upload (new, uploading, or uploaded)'
    end
    property :parse_status do
      key :type, :string
      key :enum, PARSE_STATUSES
      key :description, 'Parse status of StudyFile (unparsed, parsing, or parsed)'
    end
    property :data_dir do
      key :type, :string
      key :description, 'Local directory where File object is localized to (for parsing)'
    end
    property :remote_location do
      key :type, :string
      key :description, 'Location in GCS bucket of File object'
    end
    property :human_fastq_url do
      key :type, :string
      key :format, :url
      key :description, 'External URL for human sequence data (if required)'
    end
    property :human_data do
      key :type, :boolean
      key :default, false
      key :description, 'Boolean indication whether StudyFile represents human data'
    end
    property :use_metadata_convention do
      key :type, :boolean
      key :default, false
      key :description, 'Boolean indication whether StudyFile uses the metadata convention'
    end
    property :generation do
      key :type, :string
      key :description, 'GCS generation tag of File in bucket'
    end
    property :x_axis_label do
      key :type, :string
      key :description, 'Label to use on X axis of plots (for Clusters)'
    end
    property :y_axis_label do
      key :type, :string
      key :description, 'Label to use on Y axis of plots (for Clusters, Expression Matrix, MM Coordinate Matrix)'
    end
    property :z_axis_label do
      key :type, :string
      key :description, 'Label to use on Z axis of plots (for Clusters)'
    end
    property :x_axis_min do
      key :type, :integer
      key :description, 'X axis domain minimum (for Clusters)'
    end
    property :x_axis_max do
      key :type, :integer
      key :description, 'X axis domain maximum (for Clusters)'
    end
    property :y_axis_min do
      key :type, :integer
      key :description, 'Y axis domain minimum (for Clusters)'
    end
    property :y_axis_max do
      key :type, :integer
      key :description, 'Y axis domain maximum (for Clusters)'
    end
    property :z_axis_min do
      key :type, :integer
      key :description, 'Z axis domain minimum (for Clusters)'
    end
    property :z_axis_max do
      key :type, :integer
      key :description, 'Z axis domain maximum (for Clusters)'
    end
    property :external_link_url do
      key :type, :string
      key :description, 'URL of external link  (for Clusters)'
    end
    property :external_link_title do
      key :type, :string
      key :description, 'Title of external link  (for Clusters)'
    end
    property :external_link_description do
      key :type, :string
      key :description, 'Description of / tooltip for external link  (for Clusters)'
    end
    property :queued_for_deletion do
      key :type, :boolean
      key :default, false
      key :description, 'Boolean indication whether file is queued for garbage collection'
    end
    property :options do
      key :type, :object
      key :default, {}
      key :description, 'Key/Value storage of extra file options'
    end
    property :expression_file_info do
      key :title, :ExpressionFileInfo
      key :type, :object
      key :description, 'Expression matrix-specific file information'
      property :is_raw_counts do
        key :type, :boolean
        key :description, 'Indication of whether matrix contains raw count data'
      end
      property :units do
        key :type, :string
        key :description, 'Type of units for raw count file'
        key :enum, ExpressionFileInfo::UNITS_VALUES
      end
      property :biosample_input_type do
        key :type, :string
        key :description, 'Type of biosample input'
        key :enum, ExpressionFileInfo::BIOSAMPLE_INPUT_TYPE_VALUES
      end
      property :library_preparation_protocol do
        key :type, :string
        key :description, 'Protocol used to generate expression matrix'
        key :enum, ExpressionFileInfo::LIBRARY_PREPARATION_VALUES
      end
      property :modality do
        key :type, :string
        key :description, 'Modality type'
        key :enum, ExpressionFileInfo::MODALITY_VALUES
      end
    end
    property :created_at do
      key :type, :string
      key :format, :date_time
      key :description, 'Creation timestamp'
    end
    property :updated_at do
      key :type, :string
      key :format, :date_time
      key :description, 'Last update timestamp'
    end
  end

  swagger_schema :StudyFileInput do
    allOf do
      schema do
        property :taxon_id do
          key :type, :string
          key :description, 'Database ID of Taxon entry (species) to which StudyFile belongs, if required/present.  THIS IS NOT THE NCBI TAXON ID.'
        end
        property :species do
          key :type, :string
          key :description, '(optional) Common name of a species registered in the portal to set taxon_id association manually'
        end
        property :genome_assembly_id do
          key :type, :string
          key :description, 'ID of GenomeAssembly to which StudyFile belongs, if required/present'
        end
        property :assembly do
          key :type, :string
          key :description, '(optional) Common name of a genome assembly registered in the portal to set genome_assembly_id association manually'
        end
        property :study_file_bundle_id do
          key :type, :string
          key :description, 'ID of StudyFileBundle to which StudyFile belongs, if present'
        end
        property :name do
          key :type, :string
          key :description, 'Name of StudyFile (either filename or name of Cluster/Gene List)'
        end
        property :description do
          key :type, :string
          key :description, 'StudyFile description, used in download views'
        end
        property :file_type do
          key :type, :string
          key :enum, STUDY_FILE_TYPES
          key :description, 'Type of file, governs parsing/caching behavior'
        end
        property :status do
          key :type, :string
          key :enum, UPLOAD_STATUSES
          key :description, 'Status of File object upload (new, uploading, or uploaded)'
        end
        property :remote_location do
          key :type, :string
          key :description, 'Location in GCS bucket of File object'
        end
        property :human_fastq_url do
          key :type, :string
          key :format, :url
          key :description, 'External URL for human sequence data (if required)'
        end
        property :human_data do
          key :type, :boolean
          key :default, false
          key :description, 'Boolean indication whether StudyFile represents human data'
        end
        property :use_metadata_convention do
          key :type, :boolean
          key :default, false
          key :description, 'Boolean indication whether StudyFile uses the metadata convention'
        end
        property :generation do
          key :type, :string
          key :description, 'GCS generation tag of File in bucket'
        end
        property :x_axis_label do
          key :type, :string
          key :description, 'Label to use on X axis of plots (for Clusters)'
        end
        property :y_axis_label do
          key :type, :string
          key :description, 'Label to use on Y axis of plots (for Clusters, Expression Matrix, MM Coordinate Matrix)'
        end
        property :z_axis_label do
          key :type, :string
          key :description, 'Label to use on Z axis of plots (for Clusters)'
        end
        property :x_axis_min do
          key :type, :integer
          key :description, 'X axis domain minimum (for Clusters)'
        end
        property :x_axis_max do
          key :type, :integer
          key :description, 'X axis domain maximum (for Clusters)'
        end
        property :y_axis_min do
          key :type, :integer
          key :description, 'Y axis domain minimum (for Clusters)'
        end
        property :y_axis_max do
          key :type, :integer
          key :description, 'Y axis domain maximum (for Clusters)'
        end
        property :z_axis_min do
          key :type, :integer
          key :description, 'Z axis domain minimum (for Clusters)'
        end
        property :z_axis_max do
          key :type, :integer
          key :description, 'Z axis domain maximum (for Clusters)'
        end
        property :external_link_url do
          key :type, :string
          key :description, 'URL of external link  (for Clusters)'
        end
        property :external_link_title do
          key :type, :string
          key :description, 'Title of external link  (for Clusters)'
        end
        property :external_link_description do
          key :type, :string
          key :description, 'Description of / tooltip for external link  (for Clusters)'
        end
        property :options do
          key :type, :object
          key :default, {}
          key :description, 'Key/Value storage of extra file options'
        end
        property :expression_file_info do
          key :type, :object
          key :description, 'Expression matrix-specific file information'
          property :is_raw_counts do
            key :type, :boolean
            key :description, 'Indication of whether matrix contains raw count data'
          end
          property :units do
            key :type, :string
            key :description, 'Type of units for raw count file'
            key :enum, ExpressionFileInfo::UNITS_VALUES
          end
          property :biosample_input_type do
            key :type, :string
            key :description, 'Type of biosample input'
            key :enum, ExpressionFileInfo::BIOSAMPLE_INPUT_TYPE_VALUES
          end
          property :library_preparation_protocol do
            key :type, :string
            key :description, 'Protocol used to generate expression matrix'
            key :enum, ExpressionFileInfo::LIBRARY_PREPARATION_VALUES
          end
          property :modality do
            key :type, :string
            key :description, 'Modality type'
            key :enum, ExpressionFileInfo::MODALITY_VALUES
          end
        end
      end
    end
  end

  swagger_schema :StudyFileSync do
    allOf do
      schema do
        property :taxon_id do
          key :type, :string
          key :description, 'Database ID of Taxon entry (species) to which StudyFile belongs, if required/present.  THIS IS NOT THE NCBI TAXON ID.'
        end
        property :species do
          key :type, :string
          key :description, '(optional) Common name of a species registered in the portal to set taxon_id association manually'
        end
        property :genome_assembly_id do
          key :type, :string
          key :description, 'ID of GenomeAssembly to which StudyFile belongs, if required/present'
        end
        property :assembly do
          key :type, :string
          key :description, '(optional) Common name of a genome assembly registered in the portal to set genome_assembly_id association manually'
        end
        property :name do
          key :type, :string
          key :description, 'Name of StudyFile (either filename or name of Cluster/Gene List)'
        end
        property :description do
          key :type, :string
          key :description, 'StudyFile description, used in download views'
        end
        property :file_type do
          key :type, :string
          key :enum, STUDY_FILE_TYPES
          key :description, 'Type of file, governs parsing/caching behavior'
        end
        property :remote_location do
          key :type, :string
          key :description, 'Location in GCS bucket of File object'
        end
        property :human_data do
          key :type, :boolean
          key :default, false
          key :description, 'Boolean indication whether StudyFile represents human data'
        end
        property :use_metadata_convention do
          key :type, :boolean
          key :default, false
          key :description, 'Boolean indication whether StudyFile uses the metadata convention'
        end
        property :generation do
          key :type, :string
          key :description, 'GCS generation tag of File in bucket'
        end
        property :options do
          key :type, :object
          key :default, {}
          key :description, 'Key/Value storage of extra file options'
        end
        property :expression_file_info do
          key :type, :object
          key :description, 'Expression matrix-specific file information'
          property :is_raw_counts do
            key :type, :boolean
            key :description, 'Indication of whether matrix contains raw count data'
          end
          property :units do
            key :type, :string
            key :description, 'Type of units for raw count file'
            key :enum, ExpressionFileInfo::UNITS_VALUES
          end
          property :biosample_input_type do
            key :type, :string
            key :description, 'Type of biosample input'
            key :enum, ExpressionFileInfo::BIOSAMPLE_INPUT_TYPE_VALUES
          end
          property :library_preparation_protocol do
            key :type, :string
            key :description, 'Protocol used to generate expression matrix'
            key :enum, ExpressionFileInfo::LIBRARY_PREPARATION_VALUES
          end
          property :modality do
            key :type, :string
            key :description, 'Modality type'
            key :enum, ExpressionFileInfo::MODALITY_VALUES
          end
        end
      end
    end
  end

  swagger_schema :SiteStudyFile do
    key :name, 'StudyFile'
    property :name do
      key :type, :string
      key :description, 'Name of StudyFile (either filename or name of Cluster/Gene List)'
    end
    property :description do
      key :type, :string
      key :description, 'StudyFile description, used in download views'
    end
    property :upload_content_type do
      key :type, :string
      key :description, 'Content-Type of upload File object'
    end
    property :upload_file_size do
      key :type, :integer
      key :description, 'Size (in bytes) of upload File object'
    end
    property :file_type do
      key :type, :string
      key :enum, StudyFile::STUDY_FILE_TYPES
      key :description, 'Type of file, governs parsing/caching behavior'
    end
    property :bucket_location do
      key :type, :string
      key :description, 'Location in GCS bucket of File object'
    end
    property :download_url do
      key :type, :string
      key :description, 'URL to download file'
    end
    property :media_url do
      key :type, :string
      key :description, 'URL to stream file to client'
    end
  end

  swagger_schema :FileBundleInput do
    allOf do
      schema do
        property :name do
          key :type, :string
          key :description, 'Filename of File object'
        end
        property :file_type do
          key :type, :string
          key :enum, STUDY_FILE_TYPES
          key :description, 'File type of File object'
        end
      end
    end
  end

  ###
  #
  # VALIDATIONS & CALLBACKS
  #
  ###

  # callbacks
  before_validation :set_file_name_and_data_dir, on: :create
  before_validation :upload_from_remote_location,
                    on: :create,
                    if: proc { |f| f.remote_location.present? && f.upload_file_name.blank? }
  before_save         :sanitize_name
  after_save          :set_cluster_group_ranges, :set_options_by_file_type
  after_update        :handle_clustering_fragment_updates

  validates_uniqueness_of :upload_file_name, scope: :study_id, unless: proc { |f| f.human_data? }
  validates_uniqueness_of :remote_location, scope: :study_id,
                          unless: proc { |f| f.human_data? || f.remote_location.blank? }
  validates_presence_of :name
  validates_presence_of :human_fastq_url, if: proc { |f| f.human_data }
  validates_format_of :human_fastq_url, with: URI.regexp,
                      message: 'is not a valid URL', if: proc { |f| f.human_data }
  validate :validate_name_by_file_type

  validates_format_of :description, with: ValidationTools::NO_SCRIPT_TAGS,
                      message: ValidationTools::NO_SCRIPT_TAGS_ERROR, allow_blank: true

  validates_format_of :x_axis_label, with: ValidationTools::NO_SCRIPT_TAGS,
                      message: ValidationTools::NO_SCRIPT_TAGS_ERROR,
                      allow_blank: true
  validates_format_of :y_axis_label, with: ValidationTools::NO_SCRIPT_TAGS,
                      message: ValidationTools::NO_SCRIPT_TAGS_ERROR,
                      allow_blank: true
  validates_format_of :z_axis_label, with: ValidationTools::NO_SCRIPT_TAGS,
                      message: ValidationTools::NO_SCRIPT_TAGS_ERROR,
                      allow_blank: true

  validates_format_of :generation, with: /\A\d+\z/, if: proc { |f| f.generation.present? }

  validates_inclusion_of :file_type, in: STUDY_FILE_TYPES, unless: proc { |f| f.file_type == 'DELETE' }
  validates_uniqueness_of :external_identifier, allow_blank: true

  validate :check_taxon, on: :create
  validate :check_assembly, on: :create
  validate :ensure_metadata_singleton, if: proc { |f| f.file_type == 'Metadata' }
  validate :ensure_metadata_convention, if: proc { |f| f.file_type == 'Metadata' && !f.use_metadata_convention }

  ###
  #
  # INSTANCE METHODS
  #
  ###

  # return correct path to file based on visibility & type
  def download_path
    if hosted_externally?
      external_link_url || human_fastq_url
    else
      download_method = study.public? ? :download_file_path : :download_private_file_path
      download_args = { accession: study.accession, study_name: study.url_safe_name, filename: bucket_location }
      send(download_method, **download_args)
    end
  end

  # determine if file is hosted on an external server for downloads
  def hosted_externally?
    (external_identifier.present? && external_link_url.present?) || (upload_file_name.nil? && human_fastq_url.present?)
  end

  # JSON response for jQuery uploader
  def to_jq_upload(error=nil)
    {
        '_id' => self._id,
        'name' => read_attribute(:upload_file_name),
        'size' => read_attribute(:upload_file_size),
        'url' => download_path,
        'delete_url' => delete_study_file_study_path(self.study._id, self._id),
        'delete_type' => "DELETE"
    }
  end

  def parseable?
    PARSEABLE_TYPES.include?(self.file_type)
  end

  def parsed?
    self.parse_status == 'parsed'
  end

  def parsing?
    self.parse_status == 'parsing'
  end

  def unparsed?
    self.parse_status == 'unparsed'
  end

  # determine whether we have all necessary files to parse this file.  Mainly applies to MM Coordinate Matrices and associated 10X files
  def able_to_parse?
    if !self.parseable?
      false
    else
      case self.file_type
      when 'MM Coordinate Matrix'
        self.study_file_bundle.present? && self.study_file_bundle.completed?
      when '10X Genes File'
        self.study_file_bundle.present? && self.study_file_bundle.completed?
      when '10X Barcodes File'
        self.study_file_bundle.present? && self.study_file_bundle.completed?
      else
        true # the file is parseable and a singleton
      end
    end
  end

  # file type as a css class
  def file_type_class
    self.file_type.downcase.split.join('-') + '-file'
  end

  # generate a gs-url to this study file in the study's GCS bucket
  def gs_url
    "gs://#{self.study.bucket_id}/#{self.bucket_location}"
  end

  def api_url
    api_url = ApplicationController.firecloud_client.execute_gcloud_method(:generate_api_url, 0, self.study.bucket_id, self.bucket_location)
    api_url + '?alt=media'
  end

  # determine if a file has been uploaded
  def uploaded?
    if self.human_data?
      true # human sequence data is remote, so this is always true
    else
      self.generation.present? || self.status == 'uploaded'
    end
  end

  # determine how this file was sent to SCP
  # upload: directly through upload wizard UI
  # bucket: through upload wizard, but specifying bucket path (no file upload)
  # sync: via study sync method
  def upload_trigger
    if options[:upload_trigger]
      options[:upload_trigger]
    else
      remote_location.present? ? 'sync' : 'upload'
    end
  end

  # convert all domain ranges from floats to integers
  def convert_all_ranges
    if self.file_type == 'Cluster'
      required_vals = 4
      domain = {
          x_axis_min: self.x_axis_min.to_i == 0 ? nil : self.x_axis_min.to_i,
          x_axis_max: self.x_axis_max.to_i == 0 ? nil : self.x_axis_max.to_i,
          y_axis_min: self.y_axis_min.to_i == 0 ? nil : self.y_axis_min.to_i,
          y_axis_max: self.y_axis_max.to_i == 0 ? nil : self.y_axis_max.to_i
      }
      empty_domain = {
          x_axis_min: nil,
          x_axis_max: nil,
          y_axis_min: nil,
          y_axis_max: nil
      }
      if self.cluster_groups.first.is_3d?
        domain[:z_axis_min] = self.z_axis_min.to_i == 0 ? nil : self.z_axis_min.to_i
        domain[:z_axis_max] = self.z_axis_max.to_i == 0 ? nil : self.z_axis_max.to_i
        empty_domain[:z_axis_min] = nil
        empty_domain[:z_axis_max] = nil
        required_vals = 6
      end
      # need to clear out domain first to force persistence
      self.update(empty_domain)
      if required_vals == domain.values.compact.size
        self.update(domain)
      end
    end
  end

  # end path for a file when localizing during a parse
  def download_location
    remote_location.presence || File.join(id, 'original', upload_file_name)
  end

  # for constructing a path to a file in a Google bucket
  def bucket_location
    remote_location.presence || upload_file_name
  end

  def local_location
    path = Rails.root.join(self.study.data_store_path, self.download_location)
    if File.exist?(path)
      path
    else
      path = Rails.root.join(self.study.data_store_path, self.bucket_location)
      File.exist?(path) ? path : nil
    end
  end

  # possible bucket location of file after an ingest failure (will only persist for 30 days after failure)
  def parse_fail_bucket_location
    "parse_logs/#{self.id}/#{self.upload_file_name}"
  end

  # generate a download path to use with bulk_download
  # takes the form of :study_accession/:output_directory_name/:filename
  # supports Unix- and Windows-formatted paths
  def bulk_download_pathname(os: '')
    path = "#{study.accession}/#{output_directory_name}/#{upload_file_name}"
    RequestUtils.format_path_for_os(path, os)
  end

  # Map of StudyFile#file_type to ::BULK_DOWNLOAD_TYPES, maintaining relationship for bundled files to parent
  def bulk_download_type
    # put bundled files in a sub-directory named after the bundle parent's ID so relationship is maintained
    # make sure bundle_parent is a StudyFile (can be ClusterGroup for coordinate label files)
    if self.is_bundled? && self.bundle_parent.is_a?(StudyFile)
      bp = self.bundle_parent
      "#{bp.simplified_file_type}/#{bp.id}"
    else
      self.simplified_file_type
    end
  end

  # lump MM Coordinate Matrices & dense expression matrices together for convenience in bulk download
  def simplified_file_type
    case self.file_type
    when /Matrix/
      'Expression'
    else
      self.file_type
    end
  end

  # retrieve a directory name based on file_type
  # bundled files travel with the parent, using their parent's directory
  # dense (Expression Matrix) and sparse (MM Coordinate Matrix) are lumped together
  def output_directory_name
    self.bulk_download_type.downcase.gsub(/\s/, '_')
  end

  def is_local?
    self.local_location.present?
  end

  def is_bundled?
    self.study_file_bundle.present?
  end

  # determine if study file should have a bundle (i.e. is not valid without completed bundle)
  def should_bundle?
    StudyFileBundle::REQUIRE_BUNDLE.include?(file_type)
  end

  # gracefully check if study_file_bundle is both present and completed
  def has_completed_bundle?
    self.study_file_bundle.try(:completed?)
  end

  # get any 'bundled' files that correspond to this file
  def bundled_files
    if self.study_file_bundle.present?
      self.study_file_bundle.bundled_files
    else
      # base 'selector' for query, used to search study_file.options hash
      selector = 'options'
      query_id = self.id.to_s
      case self.file_type
      when 'MM Coordinate Matrix'
        selector += '.matrix_id'
      when 'BAM'
        selector += '.bam_id'
      when 'BED'
        selector += '.bed_id'
      when 'Cluster'
        selector += '.cluster_file_id'
        query_id = self.id.to_s
      end
      StudyFile.where(selector => query_id) # return Mongoid::Criteria to lazy-load, better performance
    end
  end

  # get the bundle 'parent' file for a bundled file (e.g. MM Coordinate Matrix that is bundled with a 10X Genes File)
  # inverse of study_file.bundled_files.  In the case of Coordinate Labels, this returns the cluster, not the file
  def bundle_parent
    if self.study_file_bundle.present?
      self.study_file_bundle.parent
    else
      case self.file_type
      when /10X/
        selector = :matrix_id
      when 'BAM Index'
        selector = :bam_id
      when 'Tab Index'
        selector = :bed_id
      when 'Coordinate Labels'
        selector = :cluster_file_id
      end
      # call find_by(id: ) to avoid Mongoid::Errors::InvalidFind
      StudyFile.find_by(id: self.options[selector])
    end
  end

  # determine if this file is a bundle parent
  def is_bundle_parent?
    if self.study_file_bundle.present?
      self.study_file_bundle.parent == self
    else
      false
    end
  end

  # helper to return all expression matrices associated with this file
  # this only deals with raw <=> processed mappings via expression_file_info, not study_file_bundles
  def associated_matrix_files(matrix_data_type)
    case matrix_data_type.to_sym
    when :raw
      study_file_ids = expression_file_info&.raw_counts_associations || []
      StudyFile.where(:id.in => study_file_ids, queued_for_deletion: false)
    when :processed
      # lazy-load all other expression matrices in study
      processed_matrices = study.expression_matrices.where(queued_for_deletion: false, :id.ne => id).any_of(
        { 'expression_file_info.is_raw_counts' => false }, { 'ann_data_file_info.has_raw_counts' => false }
      )
      processed_matrices.select { |matrix| matrix&.expression_file_info&.raw_counts_associations&.include?(id.to_s) }
    else
      [] # nil-safe return w/ no association type specified
    end
  end

  # retrieve the cluster group id from the options hash for a cluster labels file
  def coordinate_labels_font_family
    if self.options[:font_family].blank?
      'Helvetica Neue'
    else
      self.options[:font_family]
    end
  end

  # retrieve the font size from the options hash for a cluster labels file
  def coordinate_labels_font_size
    if self.options[:font_size].blank?
      10
    else
      self.options[:font_size]
    end
  end

  # retrieve the font color from the options hash for a cluster labels file
  def coordinate_labels_font_color
    if self.options[:font_color].blank?
      '#333333'
    else
      self.options[:font_color]
    end
  end

  # determine a file's content type by reading the first 2 bytes and comparing to known magic numbers
  def determine_content_type
    location = File.join(self.study.data_store_path, self.download_location)
    if !File.exist?(location)
      location = File.join(self.study.data_store_path, self.bucket_location)
    end
    signature = File.open(location).read(2)
    if signature == StudyFile::GZIP_MAGIC_NUMBER
      'application/gzip'
    else
      'text/plain'
    end
  end

  # helper method for retrieving species common name
  def species_name
    self.taxon.present? ? self.taxon.common_name : nil
  end

  # helper to return assembly name
  def genome_assembly_name
    self.genome_assembly.present? ? self.genome_assembly.name : nil
  end

  # helper to return annotation, if present
  def genome_annotation
    self.genome_assembly.present? ? self.genome_assembly.current_annotation : nil
  end

  # helper to return public link to genome annotation, if present
  def genome_annotation_link
    if self.genome_assembly.present? && self.genome_assembly.current_annotation.present?
      self.genome_assembly.current_annotation.public_annotation_link
    else
      nil
    end
  end

  # helper to return public link to genome annotation index, if present
  def genome_annotation_index_link
    if self.genome_assembly.present? && self.genome_assembly.current_annotation.present?
      self.genome_assembly.current_annotation.public_annotation_index_link
    else
      nil
    end
  end

  # quick check if file is expression-based
  def is_expression?
    ['Expression Matrix', 'MM Coordinate Matrix'].include?(file_type) ||
      !!ann_data_file_info&.has_expression ||
      !!ann_data_file_info&.has_raw_counts
  end

  # helper to identify if matrix is a raw count file
  def is_raw_counts_file?
    !!expression_file_info&.is_raw_counts || !!ann_data_file_info&.has_raw_counts
  end

  # check for when a user comes back to indicate an AnnData file has raw counts data
  def needs_raw_counts_extraction?
    is_viz_anndata? && is_raw_counts_file? && parsed? && study.expression_matrix_cells(self, matrix_type: 'raw').empty?
  end

  def is_anndata?
    file_type == 'AnnData'
  end

  # AnnData files not marked for ingest (legacy + uploads not using AnnData ingest flow)
  def is_reference_anndata?
    is_anndata? && ann_data_file_info&.reference_file
  end

  # AnnData files that are used for visualization (e.g. extracted data)
  def is_viz_anndata?
    is_anndata? && !is_reference_anndata?
  end

  # shorthand for when a file is in the deletion queue (for skipping some validations)
  def is_deleting?
    queued_for_deletion || file_type == 'DELETE'
  end

  # helper to reduce duplicates when reporting anndata ingest summaries to Mixpanel
  def has_anndata_summary?
    is_viz_anndata? && !!options[:anndata_summary]
  end

  def set_anndata_summary!
    options.merge!(anndata_summary: true)
    save
  end

  def unset_anndata_summary!
    options.merge!(anndata_summary: false)
    save
  end

  # determine if a file is gzipped by reading the first two bytes and comparing to GZIP_MAGIC_NUMBER
  def gzipped?
    File.open(local_location.to_s).read(2) == GZIP_MAGIC_NUMBER # per IETF
  end

  # quick check if file is of a type that is safe to gzip
  # essentially any file that is not a BAM/CRAM or AnnData archive
  def can_gzip?
    return false if gzipped?

    # Sequence data & AnnData files either already are or could be gzipped
    !PRIMARY_DATA_TYPES.include?(file_type) && file_type != 'AnnData' && !upload_file_name.ends_with?('.cram')
  end

  ###
  #
  # CACHING METHODS
  #
  ###

  # helper method to invalidate any matching front-end caches when parsing/deleting a study_file
  def invalidate_cache_by_file_type
    cache_key = self.cache_removal_key
    unless cache_key.nil?
      # clear matching caches in background, including API responses
      CacheRemovalJob.new(cache_key).delay(queue: :cache).perform
    end
  end

  # helper method to return cache removal key based on file type (this is refactored out for use in tests)
  def cache_removal_key
    study_name = self.study.url_safe_name
    accession = self.study.accession
    # because of the complex interactions of the expression, explore, cluster, and annotation controllers,
    # we play it safe and invalidate the study's api cache on any file upload that isn't 'other'
    file_types_not_impacting_cache = ['Documentation', 'Other']
    cache_key = nil
    if file_types_not_impacting_cache.exclude?(self.file_type)
      cache_key = "#{accession}"
    end
  end

  ###
  #
  # DELETE METHODS
  #
  ###

  # delete all queued study file objects
  def self.delete_queued_files
    study_files = self.where(queued_for_deletion: true)
    study_files.each do |file|
      Rails.logger.info "#{Time.zone.now} deleting queued file #{file.name} in study #{file.study.name}."
      file.destroy
      Rails.logger.info "#{Time.zone.now} #{file.name} successfully deleted."
    end
    true
  end

  # remove a local copy on the file system if a parse fails
  def remove_local_copy
    Rails.logger.info "Removing local copy of #{self.upload_file_name}"
    Dir.chdir(self.study.data_store_path)
    if Dir.exist?(self.id.to_s)
      Rails.logger.info "Removing upload directory for #{self.upload_file_name} at #{Dir.pwd}/#{self.id.to_s}"
      FileUtils.rm_rf(self.id.to_s)
    elsif File.exist?(self.bucket_location)
      Rails.logger.info "Removing local copy at #{Dir.pwd}/#{self.bucket_location}"
      File.delete(self.bucket_location)
    end
    Rails.logger.info "Removal of local copy of #{self.upload_file_name} complete"
  end

  # check if this file can be deleted "safely"; e.g. not being used in any running parse jobs
  # most files just need to check if they are still parsing; cluster/metadata files need to check for subsampling
  def can_delete_safely?
    if self.parsing?
      false
    else
      case self.file_type
      when 'Metadata'
        !self.study.cluster_groups.where(is_subsampling: true).any?
      when 'Cluster'
        cluster = ClusterGroup.find_by(study_file_id: self.id)
        cluster.nil? || !cluster.is_subsampling?
      else
        true
      end
    end
  end

  ##
  #
  # MISC METHODS
  #
  ##

  # get the name of form partial for the wizard uploader by file type
  def wizard_partial_name
    case self.file_type
    when 'Cluster'
      'initialize_ordinations_form'
    when 'Coordinate Labels'
      'initialize_labels_form'
    when 'Expression Matrix'
      'initialize_expression_form'
    when 'MM Coordinate Matrix'
      'initialize_expression_form'
    when '10X Genes File'
      'initialize_expression_form'
    when '10X Barcodes File'
      'initialize_expression_form'
    when 'Expression Matrix'
      'initialize_expression_form'
    when 'Metadata'
      'initialize_metadata_form'
    when 'Fastq'
      'initialize_primary_data_form'
    when 'BAM'
      'initialize_primary_data_form'
    when 'BAM Index'
      'initialize_primary_data_form'
    when 'Gene List'
      'initialize_marker_genes_form'
    else
      'initialize_misc_form'
    end
  end

  # get the ID of form for the wizard uploader by file type
  def wizard_form_id
    case self.file_type
    when 'Cluster'
      "ordinations_form_#{self.id}"
    when 'Coordinate Labels'
      "labels_form_#{self.id}"
    when 'Expression Matrix'
      "expression_form_#{self.id}"
    when 'MM Coordinate Matrix'
      "expression_form_#{self.id}"
    when '10X Genes File'
      "bundled_file_form_#{self.id}"
    when '10X Barcodes File'
      "bundled_file_form_#{self.id}"
    when 'Expression Matrix'
      "misc_form_#{self.id}"
    when 'Metadata'
      "metadata_form"
    when 'Fastq'
      "primary_data_form_#{self.id}"
    when 'BAM'
      "primary_data_form_#{self.id}"
    when 'BAM Index'
      "bundled_file_form_#{self.id}"
    when 'Gene List'
      "marker_genes_form_#{self.id}"
    else
      "misc_form_#{self.id}"
    end
  end

  # DOM ID of the parent div holding the form for this file, used in upload wizard and sync page
  def form_container_id
    "container-#{self.id}"
  end

  def generate_expression_matrix_cells
    begin
      study = self.study
      existing_array = DataArray.where(name: "#{self.name} Cells", array_type: 'cells', linear_data_type: 'Study',
                                       linear_data_id: self.study_id).any?
      unless existing_array
        remote = ApplicationController.firecloud_client.execute_gcloud_method(:get_workspace_file, 0, study.bucket_id, self.bucket_location)
        if remote.present?
          study.make_data_dir
          download_location = study.data_store_path
          if self.remote_location.blank?
            download_location = File.join(study.data_store_path, self.id)
            Dir.mkdir download_location
          elsif self.remote_location.include?('/')
            subdir = self.remote_location.split('/').first
            download_location = File.join(study.data_store_path, subdir)
          end
          msg = "#{Time.zone.now}: localizing #{self.name} in #{study.name} to #{download_location}"
          puts msg
          Rails.logger.info msg
          file_location = File.join(study.data_store_path, self.download_location)
          ApplicationController.firecloud_client.execute_gcloud_method(:download_workspace_file, 0, study.bucket_id, self.bucket_location,
                                                       download_location, verify: :none)
          content_type = self.determine_content_type
          shift_headers = true
          if content_type == 'application/gzip'
            msg = "#{Time.zone.now}: Parsing #{self.name}:#{self.id} as application/gzip"
            puts msg
            Rails.logger.info msg
            file = Zlib::GzipReader.open(file_location)
          else
            msg = "#{Time.zone.now}: Parsing #{self.name}:#{self.id} as text/plain"
            puts msg
            Rails.logger.info msg
            file = File.open(file_location, 'rb')
          end
          raw_cells = file.readline.rstrip.split(/[\t,]/).map(&:strip)
          cells = ParseUtils.sanitize_input_array(raw_cells)
          if shift_headers
            cells.shift
          end
          # close file
          file.close
          # add processed cells to known cells
          cells.each_slice(DataArray::MAX_ENTRIES).with_index do |slice, index|
            msg = "#{Time.zone.now}: Create known cells array ##{index + 1} for #{self.name}:#{self.id} in #{study.name}"
            puts msg
            Rails.logger.info msg
            known_cells = DataArray.new(name: "#{self.name} Cells", cluster_name: self.name, array_type: 'cells',
                                        array_index: index + 1, values: slice, study_file_id: self.id, study_id: self.study_id,
                                        linear_data_type: 'Study', linear_data_id: self.study_id)
            known_cells.save
          end
          msg = "#{Time.zone.now}: removing local copy of #{download_location}"
          self.remove_local_copy
        else
          msg = "#{Time.zone.now}: skipping #{self.name} in #{study.name}; remote file no longer exists"
          puts msg
          Rails.logger.error msg
        end
      else
        msg = "#{Time.zone.now}: skipping #{self.name} in #{study.name}; already processed"
        puts msg
        Rails.logger.info msg
      end
    rescue => e
      msg = "#{Time.zone.now}: error processing #{self.name} in #{self.study.name}: #{e.message}"
      puts msg
      Rails.logger.error msg
    end
  end

  # helper to return domain_ranges for clusters when launching ingest run
  def get_cluster_domain_ranges
    domain_ranges = {}
    if !self.x_axis_min.nil? && !self.x_axis_max.nil? && !self.y_axis_min.nil? && !self.y_axis_max.nil?
      domain_ranges = {
          x: [self.x_axis_min, self.x_axis_max],
          y: [self.y_axis_min, self.y_axis_max]
      }
      if !self.z_axis_min.nil? && !self.z_axis_max.nil?
        domain_ranges[:z] = [self.z_axis_min, self.z_axis_max]
      end
    end
    domain_ranges
  end

  private

  ###
  #
  # CUSTOM VALIDATIONS & CALLBACKS
  #
  ###

  # strip space from name if the file is a cluster or gene list (will cause problems when rendering)
  def sanitize_name
    if ['Gene List', 'Cluster'].include?(self.file_type)
      self.name.strip!
    end
  end

  # set name and data_dir on create
  def set_file_name_and_data_dir
    # use filename of uploaded file for "name" if upload object is present, or upload_file_name is being manually set
    if self.name.blank?
      if self.upload_file_name.present?
        self.name = self.upload_file_name
      elsif self.upload.file.present?
        self.name = self.upload.file.filename
      end
    end
    self.data_dir = self.study.data_dir
  end

  # set ranges for cluster_groups if necessary
  def set_cluster_group_ranges
    if self.file_type == 'Cluster' && self.cluster_groups.any?
      cluster = self.cluster_groups.first
      # check if range values are present and set accordingly
      if !self.x_axis_min.nil? && !self.x_axis_max.nil? && !self.y_axis_min.nil? && !self.y_axis_max.nil?
        domain_ranges = {
            x: [self.x_axis_min, self.x_axis_max],
            y: [self.y_axis_min, self.y_axis_max]
        }
        if !self.z_axis_min.nil? && !self.z_axis_max.nil?
          domain_ranges[:z] = [self.z_axis_min, self.z_axis_max]
        end
        cluster.update(domain_ranges: domain_ranges)
      else
        # either user has not supplied ranges or is deleting them, so clear entry for cluster_group
        cluster.update(domain_ranges: nil)
      end
    end
  end

  # update clustering fragments to be called on updates to study_file
  def handle_clustering_fragment_updates
    if !is_reference_anndata? && ann_data_file_info&.data_fragments_changed?
      if !queued_for_deletion && !parsing? && !name.start_with?("DELETE-")

        # collect the previous data fragment ids to choose the appropriate action for new or existing clusterings
        prev_ids = {}
        ann_data_file_info.data_fragments_was.each do |data_frag|
          prev_ids[data_frag&.[]("_id")] = data_frag
        end

        # going through the obsm key names ensures actions are only being called on clusterings
        ann_data_file_info.obsm_key_names.each do |fragment|
          matcher = { data_type: :cluster, obsm_key_name: fragment }
          cluster_data_fragment = ann_data_file_info.find_fragment(**matcher)
          curr_id = cluster_data_fragment&.[]("_id")

          # if it's an existing clustering just update don't parse
          if prev_ids.include? curr_id
            name = cluster_data_fragment&.[](:name) || fragment # fallback if we can't find data_fragment

            if prev_ids[curr_id]["name"] != name
              @cluster = ClusterGroup.find_by(
                study_id: study.id, study_file_id: id, name: prev_ids.dig(curr_id, 'name')
              )
              # before updating, check if the defaults also need to change
              if study.default_cluster == @cluster
                study.default_options[:cluster] = name
                study.save
              end
              @cluster.update(name:)
            end
          else
            persist_on_fail = remote_location.present?
            FileParseService.run_parse_job(self, study, study.user, persist_on_fail:, obsm_key: fragment)
          end
        end
      end
    end
  end


  # handler to set certain options based on a study_file's file_type
  def set_options_by_file_type
    case self.file_type
    when 'Ideogram Annotations'
      Rails.logger.info "Setting ideogram annotations options on #{self.upload_file_name}"
      unless self.options[:annotation_name].present? && self.options[:cluster_name].present?
        options_parts = self.upload_file_name.split('ideogram_exp_means__').last
        # chomp off filename header and .json at end
        options_parts.gsub!(/\.json/, '')
        cluster_name, annotation_name, annotation_type, annotation_scope = options_parts.split('--')
        annotation_identifier = [annotation_name, annotation_type, annotation_scope].join('--')
        self.update(options: {
            cluster_name: cluster_name,
            annotation_name: annotation_identifier,
            analysis_name: 'infercnv',
            visualization_name: 'ideogram.js'
        })
      end
      Rails.logger.info "Ideogram annotation successfully set on: #{self.upload_file_name}"
    end
  end

  # depending on the file_type, configure correct sanitizer for name field
  def validate_name_by_file_type
    regex = ValidationTools::FILENAME_CHARS
    error = ValidationTools::FILENAME_CHARS_ERROR
    case self.file_type
    when /(Cluster|Gene List)/
      regex = ValidationTools::OBJECT_LABELS
      error = ValidationTools::OBJECT_LABELS_ERROR
    when 'Fastq'
      if self.human_data?
        regex = ValidationTools::OBJECT_LABELS
        error = ValidationTools::OBJECT_LABELS_ERROR
      end
    end
    if self.name !~ regex
      errors.add(:name, error)
    end
  end

  # if this file is expression or sequence data, validate that the user has supplied a species/taxon
  def check_taxon
    if Taxon.present? && TAXON_REQUIRED_TYPES.include?(self.file_type) && self.taxon_id.blank?
      errors.add(:taxon_id, 'You must supply a species for this file type: ' + self.file_type)
    end
  end

  def check_assembly
    if GenomeAssembly.present? && ASSEMBLY_REQUIRED_TYPES.include?(self.file_type) && self.genome_assembly_id.nil?
      errors.add(:genome_assembly_id, 'You must supply a genome assembly for this file type: ' + self.file_type)
    end
  end

  # ensure that a user can only add one metadata file per study
  def ensure_metadata_singleton
    if StudyFile.any_of(
      { :file_type => 'Metadata', :study_id => study_id, :queued_for_deletion => false, :id.ne => id },
      {
        :file_type => 'AnnData', 'ann_data_file_info.has_metadata' => true, :study_id => study_id,
        :queued_for_deletion => false, :id.ne => id
      }
    ).exists?
      errors.add(:file_type, 'You may only add one metadata file per study')
    end
  end

  # ensure that metadata file adheres to convention acceptance criteria, if turned on
  # will check for exemption from any users associated with given study
  def ensure_metadata_convention
    convention_required = FeatureFlag.find_by(name: 'convention_required')
    return true if convention_required.nil? || convention_required.default_value == false

    # check for exemption across all associated users & study object
    user_accounts = study.associated_users(permission: 'Edit')
    unless FeatureFlaggable.flag_override_for_instances(
      'convention_required', false, *user_accounts, study
    )
      errors.add(:use_metadata_convention, 'must be "true" to ensure data complies with the SCP metadata convention')
    end
  end

  # get upload information from remote_location, if specified
  # can happen during normal upload when using bucket path option
  def upload_from_remote_location
    # first sanitize remote_location to remove gs:// or bucket_id
    trimmed_path = self.remote_location.gsub(%r{(gs://)?#{study.bucket_id}/?}, '')
    self.remote_location = trimmed_path
    remote = ApplicationController.firecloud_client.execute_gcloud_method(
      :get_workspace_file, 0, study.bucket_id, self.remote_location
    )
    if remote.nil?
      errors.add(:remote_location, "is invalid - no file found at #{self.remote_location}")
    else
      self.status = 'uploaded'
      self.upload_file_name = remote.name.split('/').last
      self.upload_content_type = remote.content_type
      self.upload_file_size = remote.size
      self.generation = remote.generation
      # special trigger setting for Mixpanel reporting
      self.options.merge!(upload_trigger: 'bucket')
    end
  end

  # show errors for nested info documents inline with top-level validation errors
  def show_nested_info_errors
    %i[expression_file_info ann_data_file_info differential_expression_file_info].each do |association_name|
      model = send(association_name)
      next if model.blank? || model.valid?

      model.errors.full_messages.each do |msg|
        errors.add(:base, msg)
      end
      errors.delete(association_name) if errors[association_name].present?
    end
  end

  # we aim to track all fields except fields that are auto-updated.
  # modifier is set to nil because unfortunately we can't easily track the user who made certain changes
  # the gem (Mongoid::Userstamp) mongoid-history recommends for doing that (which auto-sets the current_user as the modifier)
  # does not seem to work with the latest versions of mongoid
  track_history on: [:fields, :embedded_relations],
                except: [:created_at, :updated_at, :parse_status, :status, :upload_file_size, :upload_file_content, :generation],
                modifier_field: nil
end
