# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require "log4r"
require 'optparse'
require "thread"

require "vagrant/action/builtin/mixin_synced_folders"
require "vagrant/util/busy"
require "vagrant/util/platform"

require_relative "../helper"

require "listen"

module VagrantPlugins
  module SyncedFolderRSync
    module Command
      class RsyncAuto < Vagrant.plugin("2", :command)
        include Vagrant::Action::Builtin::MixinSyncedFolders

        def self.synopsis
          "syncs rsync synced folders automatically when files change"
        end

        def execute
          @logger = Log4r::Logger.new("vagrant::commands::rsync-auto")

          options = {}
          opts = OptionParser.new do |o|
            o.banner = "Usage: vagrant rsync-auto [vm-name]"
            o.separator ""
            o.separator "Options:"
            o.separator ""

            o.on("--[no-]poll", "Force polling filesystem (slow)") do |poll|
              options[:poll] = poll
            end

            o.on("--[no-]rsync-chown", "Use rsync to modify ownership") do |chown|
              options[:rsync_chown] = chown
            end
          end

          # Parse the options and return if we don't have any target.
          argv = parse_options(opts)
          return if !argv

          # Build up the paths that we need to listen to.
          paths = {}
          ignores = []
          with_target_vms(argv) do |machine|
            next if machine.state.id == :not_created
            cwd = machine.env.cwd.to_s

            if machine.provider.capability?(:proxy_machine)
              proxy = machine.provider.capability(:proxy_machine)
              if proxy
                machine.ui.warn(I18n.t(
                  "vagrant.rsync_proxy_machine",
                  name: machine.name.to_s,
                  provider: machine.provider_name.to_s))

                machine = proxy
              end
            end

            cached = synced_folders(machine, cached: true)
            fresh  = synced_folders(machine)
            diff   = synced_folders_diff(cached, fresh)
            if !diff[:added].empty?
              machine.ui.warn(I18n.t("vagrant.rsync_auto_new_folders"))
            end

            folders = cached[:rsync]
            next if !folders || folders.empty?

            # NOTE: This check is required with boot2docker since all containers
            # share the same virtual machine. This prevents rsync-auto from
            # syncing all known containers with rsync to the boot2docker vm
            # and only syncs the current working dirs folders.
            sync_folders = {}
            # Still sync existing synced folders from vagrantfile
            config_synced_folders = machine.config.vm.synced_folders.values.map { |x| x[:hostpath] }
            config_synced_folders.map! { |x| File.expand_path(x, machine.env.root_path) }
            folders.each do |id, folder_opts|
              if cwd != folder_opts[:hostpath] &&
                  !config_synced_folders.include?(folder_opts[:hostpath])

                machine.ui.info(I18n.t("vagrant.rsync_auto_remove_folder",
                                    folder: folder_opts[:hostpath]))
              else
                if options.has_key?(:rsync_chown)
                  folder_opts = folder_opts.merge(rsync_ownership: options[:rsync_chown])
                end
                sync_folders[id] = folder_opts
              end
            end
            folders = sync_folders

            # Get the SSH info for this machine so we can do an initial
            # sync to the VM.
            ssh_info = machine.ssh_info
            if ssh_info
              machine.ui.info(I18n.t("vagrant.rsync_auto_initial"))
              folders.each do |id, folder_opts|
                RsyncHelper.rsync_single(machine, ssh_info, folder_opts)
              end
            end

            folders.each do |id, folder_opts|
              # If we marked this folder to not auto sync, then
              # don't do it.
              next if folder_opts.key?(:auto) && !folder_opts[:auto]

              hostpath = folder_opts[:hostpath]
              hostpath = File.expand_path(hostpath, machine.env.root_path)
              paths[hostpath] ||= []
              paths[hostpath] << {
                id: id,
                machine: machine,
                opts:    folder_opts,
              }

              if folder_opts[:exclude]
                Array(folder_opts[:exclude]).each do |pattern|
                  ignores << RsyncHelper.exclude_to_regexp(pattern.to_s)
                end
              end

              # Always ignore Vagrant
              ignores << /.vagrant\//
              ignores.uniq!
            end
          end

          # Exit immediately if there is nothing to watch
          if paths.empty?
            @env.ui.info(I18n.t("vagrant.rsync_auto_no_paths"))
            return 1
          end

          # Output to the user what paths we'll be watching
          paths.keys.sort.each do |path|
            paths[path].each do |path_opts|
              path_opts[:machine].ui.info(I18n.t(
                "vagrant.rsync_auto_path",
                path: path.to_s,
              ))
            end
          end

          @logger.info("Listening to paths: #{paths.keys.sort.inspect}")
          @logger.info("Ignoring #{ignores.length} paths:")
          ignores.each do |ignore|
            @logger.info("  -- #{ignore.to_s}")
          end
          @logger.info("Listening via: #{Listen::Adapter.select.inspect}")
          callback = method(:callback).to_proc.curry[paths]
          listopts = { ignore: ignores, force_polling: !!options[:poll] }
          listener = Listen.to(*paths.keys, listopts, &callback)

          # Create the callback that lets us know when we've been interrupted
          queue    = Queue.new
          callback = lambda do
            # This needs to execute in another thread because Thread
            # synchronization can't happen in a trap context.
            Thread.new { queue << true }
          end

          # Run the listener in a busy block so that we can cleanly
          # exit once we receive an interrupt.
          Vagrant::Util::Busy.busy(callback) do
            listener.start
            queue.pop
            listener.stop if listener.state != :stopped
          end

          0
        end

        # This is the callback that is called when any changes happen
        def callback(paths, modified, added, removed)
          @logger.info("File change callback called!")
          @logger.info("  - Modified: #{modified.inspect}")
          @logger.info("  - Added: #{added.inspect}")
          @logger.info("  - Removed: #{removed.inspect}")

          tosync = []
          paths.each do |hostpath, folders|
            # Find out if this path should be synced
            found = catch(:done) do
              [modified, added, removed].each do |changed|
                changed.each do |listenpath|
                  throw :done, true if listenpath.start_with?(hostpath)
                end
              end

              # Make sure to return false if all else fails so that we
              # don't sync to this machine.
              false
            end

            # If it should be synced, store it for later
            tosync << folders if found
          end

          # Sync all the folders that need to be synced
          tosync.each do |folders|
            folders.each do |opts|
              # Reload so we get the latest ID
              opts[:machine].reload
              if !opts[:machine].id || opts[:machine].id == ""
                # Skip since we can't get SSH info without an ID
                next
              end

              ssh_info = opts[:machine].ssh_info
              begin
                start = Time.now
                RsyncHelper.rsync_single(opts[:machine], ssh_info, opts[:opts])
                finish = Time.now
                @logger.info("Time spent in rsync: #{finish-start} (in seconds)")
              rescue Vagrant::Errors::MachineGuestNotReady
                # Error communicating to the machine, probably a reload or
                # halt is happening. Just notify the user but don't fail out.
                opts[:machine].ui.error(I18n.t(
                  "vagrant.rsync_communicator_not_ready_callback"))
              rescue Vagrant::Errors::RSyncPostCommandError => e
                # Error executing rsync chown command
                opts[:machine].ui.error(I18n.t(
                  "vagrant.rsync_auto_post_command_error", message: e.to_s))
              rescue Vagrant::Errors::RSyncError => e
                # Error executing rsync, so show an error
                opts[:machine].ui.error(I18n.t(
                  "vagrant.rsync_auto_rsync_error", message: e.to_s))
              end
            end
          end
        end
      end
    end
  end
end
