require 'active_support/multibyte/unicode'

module Pod
  class Sandbox
    # The PathList class is designed to perform multiple glob matches against
    # a given directory. Basically, it generates a list of all the children
    # paths and matches the globs patterns against them, resulting in just one
    # access to the file system.
    #
    # @note   A PathList once it has generated the list of the paths this is
    #         updated only if explicitly requested by calling
    #         {#read_file_system}
    #
    class PathList
      # @return [Pathname] The root of the list whose files and directories
      #         are used to perform the matching operations.
      #
      attr_accessor :root

      # Initialize a new instance
      #
      # @param  [Pathname] root The root of the PathList.
      #
      def initialize(root)
        root_dir = ActiveSupport::Multibyte::Unicode.normalize(root.to_s)
        @root = Pathname.new(root_dir)
        @glob_cache = {}
      end

      # @return [Array<String>] The list of absolute the path of all the files
      #         contained in {root}.
      #
      def files
        read_file_system unless @files
        @files
      end

      # @return [Array<String>] The list of absolute the path of all the
      #         directories contained in {root}.
      #
      def dirs
        read_file_system unless @dirs
        @dirs
      end

      # @return [void] Reads the file system and populates the files and paths
      #         lists.
      #
      def read_file_system
        unless root.exist?
          raise Informative, "Attempt to read non existent folder `#{root}`."
        end
        escaped_root = escape_path_for_glob(root)

        absolute_paths = Pathname.glob(escaped_root + '**/*', File::FNM_DOTMATCH).lazy
        dirs_and_files = absolute_paths.reject { |path| path.basename.to_s =~ /^\.\.?$/ }
        dirs, files = dirs_and_files.partition { |path| File.directory?(path) }

        root_length = root.cleanpath.to_s.length + File::SEPARATOR.length
        sorted_relative_paths_from_full_paths = lambda do |paths|
          relative_paths = paths.lazy.map do |path|
            path_string = path.to_s
            path_string.slice(root_length, path_string.length - root_length)
          end
          relative_paths.sort_by(&:upcase)
        end

        @dirs = sorted_relative_paths_from_full_paths.call(dirs)
        @files = sorted_relative_paths_from_full_paths.call(files)
        @glob_cache = {}
      end

      #-----------------------------------------------------------------------#

      public

      # @!group Globbing

      # Similar to {glob} but returns the absolute paths.
      #
      # @param  [String,Array<String>] patterns
      #         @see #relative_glob
      #
      # @param  [Hash] options
      #         @see #relative_glob
      #
      # @return [Array<Pathname>]
      #
      def glob(patterns, options = {})
        relative_glob(patterns, options).map { |p| root + p }
      end

      # The list of relative paths that are case insensitively matched by a
      # given pattern. This method emulates {Dir#glob} with the
      # {File::FNM_CASEFOLD} option.
      #
      # @param  [String,Array<String>] patterns
      #         A single {Dir#glob} like pattern, or a list of patterns.
      #
      # @param  [Hash] options
      #
      # @option options [String] :dir_pattern
      #         An optional pattern to append to a pattern, if it is the path
      #         to a directory.
      #
      # @option options [Array<String>] :exclude_patterns
      #         Exclude specific paths given by those patterns.
      #
      # @option options [Array<String>] :include_dirs
      #         Additional paths to take into account for matching.
      #
      # @return [Array<Pathname>]
      #
      def relative_glob(patterns, options = {})
        return [] if patterns.empty?

        cache_key = options.merge(:patterns => patterns)
        cached_value = @glob_cache[cache_key]
        return cached_value if cached_value

        dir_pattern = options[:dir_pattern]
        exclude_patterns = options[:exclude_patterns]
        include_dirs = options[:include_dirs]

        if include_dirs
          full_list = files + dirs
        else
          full_list = files
        end

        list = Array(patterns).map do |pattern|
          if directory?(pattern) && dir_pattern
            pattern += '/' unless pattern.end_with?('/')
            pattern += dir_pattern
          end
          expanded_patterns = dir_glob_equivalent_patterns(pattern)
          full_list.select do |path|
            expanded_patterns.any? do |p|
              File.fnmatch(p, path, File::FNM_CASEFOLD | File::FNM_PATHNAME)
            end
          end
        end.flatten

        list = list.map { |path| Pathname.new(path) }
        if exclude_patterns
          exclude_options = { :dir_pattern => '**/*', :include_dirs => include_dirs }
          list -= relative_glob(exclude_patterns, exclude_options)
        end
        @glob_cache[cache_key] = list
      end

      #-----------------------------------------------------------------------#

      private

      # @!group Private helpers

      # @return [Bool] Wether a path is a directory. The result of this method
      #         computed without accessing the file system and is case
      #         insensitive.
      #
      # @param  [String, Pathname] sub_path The path that could be a directory.
      #
      def directory?(sub_path)
        sub_path = sub_path.to_s.downcase.sub(/\/$/, '')
        dirs.any? { |dir| dir.downcase == sub_path }
      end

      # @return [Array<String>] An array of patterns converted from a
      #         {Dir.glob} pattern to patterns that {File.fnmatch} can handle.
      #         This is used by the {#relative_glob} method to emulate
      #         {Dir.glob}.
      #
      #   The expansion provides support for:
      #
      #   - Literals
      #
      #       dir_glob_equivalent_patterns('{file1,file2}.{h,m}')
      #       => ["file1.h", "file1.m", "file2.h", "file2.m"]
      #
      #   - Matching the direct children of a directory with `**`
      #
      #       dir_glob_equivalent_patterns('Classes/**/file.m')
      #       => ["Classes/**/file.m", "Classes/file.m"]
      #
      # @param [String] pattern   A {Dir#glob} like pattern.
      #
      def dir_glob_equivalent_patterns(pattern)
        pattern = pattern.gsub('/**/', '{/**/,/}')
        values_by_set = {}
        pattern.scan(/\{[^}]*\}/) do |set|
          values = set.gsub(/[{}]/, '').split(',')
          values_by_set[set] = values
        end

        if values_by_set.empty?
          [pattern]
        else
          patterns = [pattern]
          values_by_set.each do |set, values|
            patterns = patterns.map do |old_pattern|
              values.map do |value|
                old_pattern.gsub(set, value)
              end
            end.flatten
          end
          patterns
        end
      end

      # Escapes the glob metacharacters from a given path so it can used in
      # Dir#glob and similar methods.
      #
      # @note   See CocoaPods/CocoaPods#862.
      #
      # @param  [String, Pathname] path
      #         The path to escape.
      #
      # @return [Pathname] The escaped path.
      #
      def escape_path_for_glob(path)
        result = path.to_s
        characters_to_escape = ['[', ']', '{', '}', '?', '*']
        characters_to_escape.each do |character|
          result.gsub!(character, "\\#{character}")
        end
        Pathname.new(result)
      end

      #-----------------------------------------------------------------------#
    end
  end
end
