# frozen_string_literal: true

#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

class Folder < ActiveRecord::Base
  def self.name_order_by_clause(table = nil)
    col = table ? "#{table}.name" : "name"
    best_unicode_collation_key(col)
  end
  include Workflow
  include SearchTermHelper

  ICON_MAKER_UNIQUE_TYPE = "icon maker icons"
  ROOT_FOLDER_NAME = "course files"
  PROFILE_PICS_FOLDER_NAME = "profile pictures"
  MY_FILES_FOLDER_NAME = "my files"
  CONVERSATION_ATTACHMENTS_FOLDER_NAME = "conversation attachments"
  STUDENT_ANNOTATION_DOCUMENTS_UNIQUE_TYPE = "student annotation documents"
  FLAMEGRAPHS_FOLDER_NAME = "flamegraphs"
  N_PLUS_ONE_DETECTION_FOLDER_NAME = "n+1 detection"

  belongs_to :context, polymorphic: %i[user group account course], optional: false
  belongs_to :cloned_item
  belongs_to :parent_folder, class_name: "Folder"
  has_many :file_attachments, class_name: "Attachment"
  has_many :active_file_attachments, -> { where("attachments.file_state<>'deleted'") }, class_name: "Attachment"
  has_many :visible_file_attachments, -> { where(file_state: ["available", "public"]) }, class_name: "Attachment"
  has_many :sub_folders, class_name: "Folder", foreign_key: "parent_folder_id", dependent: :destroy, inverse_of: :parent_folder
  has_many :active_sub_folders, -> { where("folders.workflow_state<>'deleted'") }, class_name: "Folder", foreign_key: "parent_folder_id", dependent: :destroy, inverse_of: :parent_folder

  acts_as_list scope: :parent_folder

  before_create :populate_root_account_id
  before_save :infer_full_name
  after_save :update_sub_folders
  after_save :touch_context
  before_save :infer_hidden_state
  validates :context_id, :context_type, presence: true
  validates :name, length: { maximum: maximum_string_length }
  validate :protect_root_folder_name, if: :name_changed?
  validate :reject_recursive_folder_structures, on: :update
  validate :restrict_submission_folder_context
  after_commit :clear_permissions_cache, if: -> { %i[workflow_state parent_folder_id locked lock_at unlock_at].any? { |k| saved_changes.key?(k) } }

  def file_attachments_visible_to(user)
    if context.grants_any_right?(user, *RoleOverride::GRANULAR_FILE_PERMISSIONS) ||
       grants_right?(user, :read_as_admin)
      active_file_attachments
    else
      visible_file_attachments.not_locked
    end
  end

  def populate_root_account_id
    self.root_account_id = if context_type == "User"
                             0
                           elsif context_type == "Account" && context.root_account?
                             context_id
                           else
                             context.root_account_id
                           end
  end

  def protect_root_folder_name
    if parent_folder_id.blank? && name != Folder.root_folder_name_for_context(context)
      if new_record?
        root_folder = Folder.root_folders(context).first
        self.parent_folder_id = root_folder.id
        true
      else
        errors.add(:name, t("errors.invalid_root_folder_name", "Root folder name cannot be changed"))
        false
      end
    end
  end

  def reject_recursive_folder_structures
    return true unless parent_folder_id_changed?

    seen_folders = Set.new([self])
    folder = self
    while folder.parent_folder
      folder = folder.parent_folder
      if seen_folders.include?(folder)
        errors.add(:parent_folder_id, t("errors.invalid_recursion", "A folder cannot be the parent of itself"))
        return false
      end
      seen_folders << folder
    end
    true
  end

  def restrict_submission_folder_context
    if for_submissions? && %w[User Group].exclude?(context_type)
      errors.add(:submission_context_code, t("submissions folders must be created in User or Group context"))
      return false
    end
    true
  end

  workflow do
    state :visible
    state :hidden
    state :deleted
  end

  alias_method :destroy_permanently!, :destroy

  def destroy
    shard.activate do
      loop do
        folder_count = 1000
        Folder.transaction do
          associated_folders = Folder.find_by_sql(<<~SQL.squish)
            WITH RECURSIVE associated_folders AS (
              SELECT * FROM #{Folder.quoted_table_name} WHERE id=#{id}
              UNION
              SELECT folders.* FROM #{Folder.quoted_table_name} INNER JOIN associated_folders ON folders.parent_folder_id=associated_folders.id
            )
            SELECT id FROM associated_folders WHERE associated_folders.workflow_state <> 'deleted' ORDER BY id LIMIT 1000 FOR UPDATE
          SQL
          Attachment.batch_destroy(Attachment.not_deleted.where(folder_id: associated_folders).order(:id))
          delete_time = Time.now.utc
          Folder.where(id: associated_folders).update_all(workflow_state: "deleted", deleted_at: delete_time, updated_at: delete_time)
          folder_count = associated_folders.length
        end
        break if folder_count < 1000
      end
      touch_context
    end
    reload
  end

  scope :active, -> { where("folders.workflow_state<>'deleted'") }
  scope :not_hidden, -> { where("folders.workflow_state<>'hidden'") }
  scope :not_locked, lambda {
    where("(folders.locked IS NULL OR folders.locked=?) AND ((folders.lock_at IS NULL) OR
    (folders.lock_at>? OR (folders.unlock_at IS NOT NULL AND folders.unlock_at<?)))",
          false,
          Time.now.utc,
          Time.now.utc)
  }
  scope :by_position, -> { ordered }
  scope :by_name, -> { order(name_order_by_clause("folders"), :id) }

  def display_name
    name
  end

  def full_name(reload = false)
    return super() if !reload && super()

    folder = self
    names = [name]
    while folder.parent_folder_id
      folder = Folder.find(folder.parent_folder_id) # folder.parent_folder
      names << folder.name if folder
    end
    names.reverse.join("/")
  end

  def infer_hidden_state
    self.workflow_state ||= parent_folder.workflow_state if parent_folder && !deleted?
  end

  protected :infer_hidden_state

  def infer_full_name
    # TODO: i18n
    t :default_folder_name, "New Folder"
    self.name = "New Folder" if name.blank?
    self.name = name.strip.tr("/", "_")
    @update_sub_folders = false
    self.parent_folder_id = nil if !parent_folder || parent_folder.context != context || parent_folder_id == id
    self.context = parent_folder.context if parent_folder
    prevent_duplicate_name
    self.full_name = full_name(true)
    if parent_folder_id_changed? || !parent_folder_id || full_name_changed? || name_changed?
      @update_sub_folders = true
    end
    @folder_id = id
  end

  protected :infer_full_name

  def prevent_duplicate_name
    return unless parent_folder

    existing_folders = parent_folder.active_sub_folders.where("name ~* ? AND id <> ?", "^#{Regexp.quote(name)}(\\s\\d+)?$", id.to_i).pluck(:name)

    return unless existing_folders.include?(name)

    iterations, usable_iterator, candidate = [], nil, 2

    existing_folders.each do |folder_name|
      iterator = folder_name.split.last.to_i
      iterations << iterator if iterator > 1
    end

    iterations.sort.each do |i|
      if candidate < i
        usable_iterator = candidate
        break
      else
        candidate = i + 1
      end
    end

    usable_iterator ||= existing_folders.size + 1
    self.name = "#{name} #{usable_iterator}"
  end

  protected :prevent_duplicate_name

  def update_sub_folders
    return unless @update_sub_folders

    sub_folders.each do |f|
      f.reload
      f.full_name = f.full_name(true)
      f.save
    end
  end

  def subcontent(opts = {})
    res = []
    res += active_sub_folders
    res += active_file_attachments unless opts[:exclude_files]
    res
  end

  def visible?
    return @visible if defined?(@visible)

    @visible = (self.workflow_state == "visible") && (!parent_folder || parent_folder.visible?)
  end

  def hidden?
    return @hidden if defined?(@hidden)

    @hidden = self.workflow_state == "hidden" || parent_folder&.hidden?
  end

  def hidden
    hidden?
  end

  def hidden=(val)
    self.workflow_state = ((val == true || val == "1" || val == "true") ? "hidden" : "visible")
  end

  def just_hide
    self.workflow_state == "hidden"
  end

  def public?
    return @public if defined?(@public)

    @public = self.workflow_state == "public" || parent_folder&.public?
  end

  def mime_class
    "folder"
  end

  # true if there are any active files or folders
  def has_contents?
    active_file_attachments.any? || active_sub_folders.any?
  end

  attr_accessor :clone_updated

  def clone_for(context, dup = nil, options = {})
    if !cloned_item && !new_record?
      self.cloned_item ||= ClonedItem.create(original_item: self)
      save!
    end
    existing = context.folders.active.where(id: self).first
    existing ||= context.folders.active.where(cloned_item_id: cloned_item_id || 0).first
    return existing if existing && !options[:overwrite] && !options[:force_copy]

    dup ||= Folder.new
    dup = existing if existing && options[:overwrite]
    attributes.except("id", "full_name", "parent_folder_id").each do |key, val|
      dup.send(:"#{key}=", val)
    end
    if unique_type && context.folders.active.where(unique_type:).exists?
      dup.unique_type = nil # we'll just copy the folder as a normal one and leave the existing unique_type'd one alone
    end
    dup.context = context
    if options[:include_subcontent] != false
      dup.save!
      subcontent.each do |item|
        next unless options[:everything] || options[:all_files] || options[item.asset_string.to_sym]

        case item
        when Attachment
          file = item.clone_for(context, nil, options.slice(:overwrite, :force_copy))
          file.folder_id = dup.id
          file.save_without_broadcasting!
        when Folder
          sub = item.clone_for(context, nil, options)
          sub.parent_folder_id = dup.id
          sub.save!
        end
      end
    end
    dup.updated_at = Time.zone.now
    dup.clone_updated = true
    dup
  end

  def root_folder?
    !parent_folder_id
  end

  def self.root_folder_name_for_context(context)
    case context
    when Course
      ROOT_FOLDER_NAME
    when User
      MY_FILES_FOLDER_NAME
    else
      "files"
    end
  end

  def self.root_folders(context)
    name = root_folder_name_for_context(context)
    root_folders = []
    # something that doesn't have folders?!
    return root_folders unless context.respond_to?(:folders)

    context.shard.activate do
      root_folder = get_or_create_root_folder_for(context, name)
      root_folders = Array(root_folder)
    end

    root_folders
  end

  # Optimized method to preload and find root folders for multiple contexts
  # This eliminates N+1 queries when loading root folders for multiple contexts
  def self.preload_root_folders(contexts)
    return {} if contexts.empty?

    # Group contexts by type and shard to minimize cross-shard queries
    contexts_by_type = contexts.group_by { |c| c.class.to_s }
    result = {}

    contexts_by_type.each do |context_type, contexts_of_type|
      next unless contexts_of_type.first.respond_to?(:folders)

      folder_name = root_folder_name_for_context(contexts_of_type.first)
      contexts_by_shard = contexts_of_type.group_by(&:shard)
      current_shard = Shard.current

      contexts_by_shard.each do |shard, shard_contexts|
        shard.activate do
          # Get all possible context IDs for this type and shard
          context_ids = shard_contexts.map(&:id)

          # Load all root folders for these contexts in a single query
          found_folders = where(
            context_type:,
            context_id: context_ids,
            parent_folder_id: nil,
            name: folder_name
          ).where("folders.workflow_state<>'deleted'").preload(:context).to_a

          # Reference the asset string from the same shard the controller will use
          current_shard.activate do
            # Index by context asset string for easy lookup
            found_folders.each do |folder|
              result[folder.context.asset_string] = folder
            end
          end

          # For contexts that don't have a root folder yet, we need to create them
          missing_context_ids = context_ids - found_folders.map(&:context_id)
          missing_context_ids.each do |context_id|
            context = shard_contexts.find { |c| c.id == context_id }
            root_folder = GuardRail.activate(:primary) do
              folder = context.folders.build(name: folder_name, full_name: folder_name, workflow_state: "visible")
              folder.insert(on_conflict: -> { get_root_folder_for(context, folder_name) })
            end
            current_shard.activate do
              result[context.asset_string] = root_folder
            end
          end
        end
      end
    end

    result
  end

  def self.get_or_create_root_folder_for(context, name)
    root_folder = get_root_folder_for(context, name)
    root_folder || GuardRail.activate(:primary) do
      folder = context.folders.build(name:, full_name: name, workflow_state: "visible")
      folder.insert(on_conflict: -> { get_root_folder_for(context, name) })
    end
  end

  def self.get_root_folder_for(context, name)
    context.folders.active.where(parent_folder_id: nil, name:).first
  end

  def self.unique_folder(context, unique_type, default_name_proc)
    folder = nil
    context.shard.activate do
      Folder.unique_constraint_retry do
        folder = context.folders.active.find_by(unique_type:)
        folder ||= context.folders.create!(unique_type:,
                                           name: default_name_proc.call,
                                           parent_folder_id: Folder.root_folders(context).first,
                                           workflow_state: "hidden")
      end
    end
    folder
  end

  def self.icon_maker_folder(context)
    unique_folder(context, ICON_MAKER_UNIQUE_TYPE, -> { t("Icon Maker Icons") })
  end

  MEDIA_TYPE = "media"
  def self.media_folder(context)
    unique_folder(context, MEDIA_TYPE, -> { t("Uploaded Media") })
  end

  def self.is_locked?(folder_id)
    RequestCache.cache("folder_is_locked", folder_id) do
      folder = Folder.where(id: folder_id).first
      folder&.locked?
    end
  end

  def attachments
    file_attachments
  end

  # if a block is given, it'll be called with each new folder created by this
  # method before the folder is saved
  def self.assert_path(path, context, conditions: {})
    @@path_lookups ||= {}
    key = [context.global_asset_string, path].join("//")
    return @@path_lookups[key] if @@path_lookups[key]

    folders = path.split("/").reject(&:empty?)
    @@root_folders ||= {}
    current_folder = (@@root_folders[context.global_asset_string] ||= Folder.root_folders(context).first)
    if folders[0] == current_folder.name
      folders.shift
    end
    folders.each do |name|
      sub_folder = @@path_lookups[[context.global_asset_string, current_folder.full_name + "/" + name].join("//")]
      sub_folder ||= current_folder.sub_folders.active.where({ name: }.merge(conditions)).first_or_initialize
      current_folder = sub_folder
      if current_folder.new_record?
        current_folder.context = context
        yield current_folder if block_given?
        current_folder.save!
      end
      @@path_lookups[[context.global_asset_string, current_folder.full_name].join("//")] ||= current_folder
    end
    @@path_lookups[key] = current_folder
  end

  def self.reset_path_lookups!
    @@root_folders = {}
    @@path_lookups = {}
  end

  def self.unfiled_folder(context)
    return unless context.respond_to?(:folders)

    parent_folder = Folder.root_folders(context).first
    folder = context.folders
                    .where(parent_folder_id: parent_folder, workflow_state: "visible")
                    .where(%q{name SIMILAR TO 'unfiled(\s\d+)?'})
                    .first
    unless folder
      folder = context.folders.build(parent_folder:, name: "unfiled")
      folder.workflow_state = "visible"
      folder.save!
    end
    folder
  end

  def self.find_attachment_in_context_with_path(context, path)
    components = path.split("/")
    component = components.shift
    context.folders.active.where(parent_folder_id: nil).each do |folder|
      if folder.name == component
        attachment = folder.find_attachment_with_components(components.dup)
        return attachment if attachment
      end
    end
    nil
  end

  def find_attachment_with_components(components)
    component = components.shift
    if components.empty?
      # find the attachment
      atts = visible_file_attachments.to_a
      return atts.detect { |a| Attachment.matches_name?(a.display_name, component) } || atts.detect { |a| Attachment.matches_name?(a.filename, component) }
    else
      # find a subfolder and recurse (yes, we can have multiple sub-folders w/ the same name)
      active_sub_folders.where(name: component).each do |folder|
        a = folder.find_attachment_with_components(components.dup)
        return a if a
      end
    end
    nil
  end

  def get_folders_by_component(components, include_hidden_and_locked)
    return [self] if components.empty?

    components = components.dup
    subfolder_name = components.shift
    # search all subfolders with the given name (yes, there can be duplicates)
    scope = active_sub_folders.where(name: subfolder_name)
    scope = scope.not_hidden.not_locked unless include_hidden_and_locked
    scope.each do |subfolder|
      sub_components = subfolder.get_folders_by_component(components, include_hidden_and_locked)
      return [self] + sub_components if sub_components
    end
    nil
  end

  def self.resolve_path(context, path, include_hidden_and_locked = true)
    path_components = case path
                      when Array
                        path
                      when String
                        path.split("/")
                      else
                        []
                      end

    Folder.root_folders(context).each do |root_folder|
      folders = root_folder.get_folders_by_component(path_components, include_hidden_and_locked)
      return folders if folders
    end
    nil
  end

  def locked?
    return @locked if defined?(@locked)

    @locked = locked ||
              (lock_at && Time.zone.now > lock_at) ||
              (unlock_at && Time.zone.now < unlock_at) ||
              parent_folder&.locked?
  end

  def for_student_annotation_documents?
    unique_type == Folder::STUDENT_ANNOTATION_DOCUMENTS_UNIQUE_TYPE
  end

  def for_submissions?
    !submission_context_code.nil?
  end

  def currently_locked
    locked || (lock_at && Time.zone.now > lock_at) || (unlock_at && Time.zone.now < unlock_at) || self.workflow_state == "hidden"
  end

  alias_method :currently_locked?, :currently_locked

  set_policy do
    given { |user, session| visible? && context.grants_right?(user, session, :read_files) }
    can :read

    given { |user, session| context.grants_right?(user, session, :read_as_admin) }
    can :read_as_admin, :read_contents, :read_contents_for_export

    given do |user, session|
      visible? && !locked? && context.grants_right?(user, session, :read_files) &&
        !(context.is_a?(Course) && context.tab_hidden?(Course::TAB_FILES))
    end
    can :read_contents

    given do |user, session|
      !locked? && context.grants_right?(user, session, :read_files)
    end
    can :read_contents_for_export

    given do |user, session|
      context.grants_any_right?(user, session, :manage_files_add, :manage_files_delete, :manage_files_edit)
    end
    can :read and can :read_contents

    given do |user, session|
      !for_submissions? && context.grants_right?(user, session, :manage_files_add)
    end
    can :create and can :manage_contents

    given do |user, session|
      !for_submissions? && context.grants_right?(user, session, :manage_files_edit)
    end
    can :update and can :manage_contents

    given do |user, session|
      !for_submissions? && context.grants_right?(user, session, :manage_files_delete)
    end
    can :delete and can :manage_contents
  end

  # find all unlocked/visible folders that can be reached by following unlocked/visible folders from the root
  def self.all_visible_folder_ids(context)
    folder_tree = context.active_folders.not_hidden.not_locked.pluck(:id, :parent_folder_id).each_with_object({}) do |row, folders|
      id, parent_folder_id = row
      folders[parent_folder_id] ||= []
      folders[parent_folder_id] << id
    end
    visible_ids = []
    dir_contents = Folder.root_folders(context).map(&:id)
    find_visible_folders(visible_ids, folder_tree, dir_contents)
    visible_ids
  end

  def self.from_context_or_id(context, id)
    root_folders(context).first || where(id:).first || (raise ActiveRecord::RecordNotFound)
  end

  def self.find_visible_folders(visible_ids, folder_tree, dir_contents)
    visible_ids.concat dir_contents
    dir_contents.each do |child_folder_id|
      next unless folder_tree[child_folder_id].present?

      find_visible_folders(visible_ids, folder_tree, folder_tree[child_folder_id])
    end
    nil
  end
  private_class_method :find_visible_folders

  def clear_permissions_cache
    GuardRail.activate(:primary) do
      delay_if_production(singleton: "clear_downstream_permissions_#{global_id}").clear_downstream_permissions
      next_clear_cache = next_lock_change
      if next_clear_cache.present? && next_clear_cache < (Time.zone.now + AdheresToPolicy::Cache::CACHE_EXPIRES_IN)
        delay(run_at: next_clear_cache, singleton: "clear_permissions_cache_#{global_id}").clear_permissions_cache
        delay(run_at: next_clear_cache, singleton: "clear_active_users_cache_#{global_id}").clear_active_users_cache
      end
    end
  end

  def clear_downstream_permissions
    active_file_attachments.touch_all
    active_sub_folders.each(&:clear_permissions_cache)
  end

  def clear_active_users_cache
    context.active_users.each(&:clear_caches) if context.is_a?(Course)
  end

  def next_lock_change
    [lock_at, unlock_at].compact.select { |t| t > Time.zone.now }.min
  end

  def restore
    return unless self.workflow_state == "deleted"

    self.workflow_state = "visible"
    if save
      parent_folder&.restore
    end
  end
end
