# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2

# Copyright 2013-present Facebook
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module BetweenMeals
  module Changes
    # Changeset aware cookbook
    class Cookbook < Change
      def self.meaningful_cookbook_file?(path)
        !explode_path(path).nil?
      end

      def self.explode_path(path)
        @cookbook_dirs.each do |dir|
          re = %r{^#{dir}/([^/]+)/.*}
          debug("[cookbook] Matching #{path} against ^#{re}")
          m = path.match(re)
          next unless m

          info("Cookbook is #{m[1]}")
          return {
            :cookbook_dir => dir,
            :name => m[1],
          }
        end
        nil
      end

      def self.map_symlinks(files)
        # For each symlink get the source path, if any files have changed under
        # the source path, fake them as coming from the symlink path. This
        # allows the normal cookbook logic to just work.
        symlinks = {} #: Hash[String, Hash[String, String]]
        @cookbook_dirs.each do |dir|
          dir = File.join(@repo_dir, dir)
          # Find symlinks in each cookbook_dir
          links = Dir.foreach(dir).select do |d|
            File.symlink?(File.join(dir, d))
          end
          links.each do |link|
            link = File.join(dir, link)
            next if symlinks[link]

            source = File.realpath(link)
            repo = File.join(@repo_dir, '/')
            # maps absolute symlink path to relative source and link paths
            symlinks[link] = {
              'source' => source.gsub(repo, ''),
              'link' => link.gsub(repo, ''),
            }
          end
        end

        # Create the file hash expected for each file that is a link or coming
        # from a linked directory but fake the source path as a symlink path.
        # Hacky but works :)
        links_to_append = [] #: Array[untyped]
        symlinks.each_value do |lrp| # link_abs_path, link_relative_path
          files.each do |f|
            # a symlink will never have trailing '/', add one.
            f[:path] += '/' if f[:path] == lrp['link']

            # If a metadata file in the path of a symlink target directory has a
            # deleted status, check if a metadata file exists in the symlink
            # source directory. If so, mark it as modified to prevent deletion.
            symlink_source_dir = File.join(@repo_dir, lrp['source'])
            if (f[:path] == File.join(lrp['link'], 'metadata.rb') ||
                f[:path] == File.join(lrp['link'], 'metadata.json')) &&
                f[:status] == :deleted &&
                (File.file?(File.join(symlink_source_dir, 'metadata.rb')) ||
                 File.file?(File.join(symlink_source_dir, 'metadata.json')))
              f[:status] = :modified
            end

            next unless f[:path].start_with?(lrp['source'])

            # This make a deep dup of the file hash
            l = Marshal.load(Marshal.dump(f))
            l[:path].gsub!(lrp['source'], lrp['link'])
            links_to_append << l
          end
        end
        links_to_append
      end

      def initialize(files, cookbook_dirs)
        @files = files
        @cookbook_dirs = cookbook_dirs
        @name = self.class.explode_path(files.sample[:path])[:name]
        # if metadata.(json|rb) is being deleted and we aren't also
        # adding/modifying one of those two,
        #   cookbook is marked for deletion
        # otherwise it was modified
        #   and will be re-uploaded
        if files.
           select { |x| x[:status] == :deleted }.
           map do |x|
             x[:path].match(
               %{^(#{cookbook_dirs.join('|')})/[^/]+/metadata\.(rb|json)$},
             )
           end.
           compact.
           any? &&
           files.reject { |x| x[:status] == :deleted }.
           map do |x|
             x[:path].match(
               %{^(#{cookbook_dirs.join('|')})/[^/]+/metadata\.(rb|json)$},
             )
           end.none?
          self.status = :deleted
        else
          self.status = :modified
        end
      end

      # Given a list of changed files
      # create a list of Cookbook objects
      def self.find(list, cookbook_dirs, logger, repo, track_symlinks = false)
        # rubocop:disable ClassVars
        @@logger = logger
        # rubocop:enable ClassVars
        return [] if list.nil? || list.empty?

        # rubocop:disable MultilineBlockChain
        @repo_dir = File.realpath(repo.repo_path)
        @cookbook_dirs = cookbook_dirs
        list += map_symlinks(list) if track_symlinks
        list.
          group_by do |x|
          # Group by prefix of cookbok_dir + cookbook_name
          # so that we treat deletes and modifications across
          # two locations separately
          g = self.explode_path(x[:path])
          g[:cookbook_dir] + '/' + g[:name] if g
        end.
          map do |_, change|
          # Confirm we're dealing with a cookbook
          # Changes to OWNERS or other stuff that might end up
          # in [core, other, secure] dirs are ignored
          is_cookbook = change.select do |c|
            self.meaningful_cookbook_file?(c[:path])
          end.any?
          if is_cookbook
            BetweenMeals::Changes::Cookbook.new(change, @cookbook_dirs)
          end
        end.compact
        # rubocop:enable MultilineBlockChain
      end
    end
  end
end
