require 'r10k/logging'

require 'fileutils'

module R10K
  module Util

    # Mixin for purging stale directory contents.
    #
    # @abstract Classes using this mixin need to implement {#managed_directory} and
    #   {#desired_contents}
    module Purgeable

      include R10K::Logging

      HIDDEN_FILE = /\.[^.]+/

      FN_MATCH_OPTS = File::FNM_PATHNAME | File::FNM_DOTMATCH

      # @deprecated
      #
      # @!method logger
      #   @abstract Including classes must provide a logger method
      #   @return [Log4r::Logger]

      # @!method desired_contents
      #   @abstract Including classes must implement this method to list the
      #     expected filenames of managed_directories
      #   @return [Array<String>] The full paths to all the content this object is managing

      # @!method managed_directories
      #   @abstract Including classes must implement this method to return an array of
      #     paths that can be purged
      #   @return [Array<String>] The paths to the directories to be purged

      # @return [Array<String>] The present directory entries in `self.managed_directories`
      def current_contents(recurse)
        dirs = self.managed_directories

        dirs.flat_map do |dir|
          if recurse
            glob_exp = File.join(dir, '**', '{*,.[^.]*}')
          else
            glob_exp = File.join(dir, '*')
          end

          Dir.glob(glob_exp)
        end
      end

      # @deprecated Unused helper function
      #
      # @return [Array<String>] Directory contents that are expected but not present
      def pending_contents(recurse)
        desired_contents - current_contents(recurse)
      end

      def matches?(test, path)
        if test == path
          true
        elsif File.fnmatch?(test, path, FN_MATCH_OPTS)
          true
        else
          false
        end
      end

      # A method to collect potentially purgeable content without searching into
      # ignored directories when recursively searching.
      #
      # @param dir [String, Pathname] The directory to search for purgeable content
      # @param exclusion_gobs [Array<String>] A list of file paths or File globs
      #     to exclude from recursion (These are generated by the classes that
      #     mix this module into them and are typically programatically generated)
      # @param allowed_gobs [Array<String>] A list of file paths or File globs to exclude
      #     from recursion (These are passed in by the caller of purge! and typically
      #     are user supplied configuration values)
      # @param desireds_not_to_recurse_into [Array<String>] A list of file paths not to
      #     recurse into. These are programatically generated, these exist to maintain
      #     backwards compatibility with previous implementations that used File.globs
      #     for "recursion", ie "**/{*,.[^.]*}" which would not recurse into dot directories.
      # @param recurse [Boolean] Whether or not to recurse into child directories that do
      #     not match other filters.
      #
      # @return [Array<String>] Contents which may be purged.
      def potentially_purgeable(dir, exclusion_globs, allowed_globs, desireds_not_to_recurse_into, recurse)
        children = Pathname.new(dir).children.reject do |path|
          path = path.to_s

          if exclusion_match = exclusion_globs.find { |exclusion| matches?(exclusion, path) }
            logger.debug2 _("Not purging %{path} due to internal exclusion match: %{exclusion_match}") % {path: path, exclusion_match: exclusion_match}
          elsif allowlist_match = allowed_globs.find { |allowed| matches?(allowed, path) }
            logger.debug _("Not purging %{path} due to whitelist match: %{allowlist_match}") % {path: path, allowlist_match: allowlist_match}
          else
            desired_match = desireds_not_to_recurse_into.grep(path).first
          end

          !!exclusion_match || !!allowlist_match || !!desired_match
        end

        children.flat_map do |child|
          if File.directory?(child) && !File.symlink?(child) && recurse
            potentially_purgeable(child, exclusion_globs, allowed_globs, desireds_not_to_recurse_into, recurse) << child.to_s
          else
            child.to_s
          end
        end
      end

      # @return [Array<String>] Directory contents that are present but not expected
      def stale_contents(recurse, exclusions, whitelist)
        dirs = self.managed_directories
        desireds = self.desired_contents
        hidden_desireds, regular_desireds = desireds.partition do |desired|
          HIDDEN_FILE.match(File.basename(desired))
        end

        initial_purgelist = dirs.flat_map do |dir|
          potentially_purgeable(dir, exclusions, whitelist, hidden_desireds, recurse)
        end

        initial_purgelist.reject do |path|
          regular_desireds.any? { |desired| matches?(desired, path) }
        end
      end

      # Forcibly remove all unmanaged content in `self.managed_directories`
      def purge!(opts={})
        recurse = opts[:recurse] || false
        whitelist = opts[:whitelist] || []

        exclusions = self.respond_to?(:purge_exclusions) ? purge_exclusions : []

        stale = stale_contents(recurse, exclusions, whitelist)

        if stale.empty?
          logger.debug1 _("No unmanaged contents in %{managed_dirs}, nothing to purge") % {managed_dirs: managed_directories.join(', ')}
        else
          stale.each do |fpath|
            begin
              FileUtils.rm_r(fpath, :secure => true)
              logger.info _("Removing unmanaged path %{path}") % {path: fpath}
            rescue Errno::ENOENT
              # Don't log on ENOENT since we may encounter that from recursively deleting
              # this item's parent earlier in the purge.
            rescue
              logger.debug1 _("Unable to remove unmanaged path: %{path}") % {path: fpath}
            end
          end
        end
      end
    end
  end
end
