require "fileutils"

module ShopifyCli
  ##
  # ProcessSupervision wraps a running process spawned by `exec` and keeps track
  # if its `pid` and keeps a log file for it as well
  class ProcessSupervision
    # a string or a symbol to identify this process by
    attr_reader :identifier
    # process ID for the running process
    attr_accessor :pid
    # starttime of the process
    attr_reader :time
    # filepath to the pidfile for this process
    attr_reader :pid_path
    # filepath to the logfile for this process
    attr_reader :log_path

    class << self
      def run_dir
        # is the directory where the pid and logfile are kept
        File.join(ShopifyCli.cache_dir, "sv")
      end

      ##
      # Will find and create a new instance of ProcessSupervision for a running process
      # if it is currently running. It will return nil if the process is not running.
      #
      # #### Parameters
      #
      # * `identifier` - a string or a symbol that a process was started with
      #
      # #### Returns
      #
      # * `process` - ProcessSupervision instance if the process is running this
      #   will be nil if the process is not running.
      #
      def for_ident(identifier)
        pid, time = File.read(File.join(ShopifyCli::ProcessSupervision.run_dir, "#{identifier}.pid")).split(":")
        new(identifier, pid: Integer(pid), time: time)
      rescue Errno::ENOENT
        nil
      end

      ##
      # will fork and spawn a new process that is separate from the current process.
      # This process will keep running beyond the command running so be careful!
      #
      # #### Parameters
      #
      # * `identifier` - a string or symbol to identify the new process by.
      # * `args` - a command to run, either a string or array of strings
      #
      # #### Returns
      #
      # * `process` - ProcessSupervision instance if the process is running, this
      #   will be nil if the process did not start.
      #
      def start(identifier, args)
        return for_ident(identifier) if running?(identifier)

        # Windows doesn't support forking process without extra gems, so we resort to spawning a new child process -
        # that means that it dies along with the original process if it is interrupted. On UNIX, we fork the process so
        # that it doesn't have to be restarted on every run.
        if Context.new.windows?
          pid_file = new(identifier)

          # Make sure the file exists and is empty, otherwise Windows fails
          File.open(pid_file.log_path, "w") {}
          pid = Process.spawn(
            *args,
            out: pid_file.log_path,
            err: pid_file.log_path,
            in: Context.new.windows? ? "nul" : "/dev/null",
          )
          pid_file.pid = pid
          pid_file.write

          Process.detach(pid)
        else
          fork do
            pid_file = new(identifier, pid: Process.pid)
            pid_file.write
            STDOUT.reopen(pid_file.log_path, "w")
            STDERR.reopen(pid_file.log_path, "w")
            STDIN.reopen("/dev/null", "r")
            Process.setsid
            exec(*args)
          end
        end

        sleep(0.1)
        for_ident(identifier)
      end

      ##
      # will attempt to shutdown a running process
      #
      # #### Parameters
      #
      # * `identifier` - a string or symbol to identify the new process by.
      #
      # #### Returns
      #
      # * `stopped` - [true, false]
      #
      def stop(identifier)
        process = for_ident(identifier)
        return false unless process
        process.stop
      end

      ##
      # will help identify if your process is still running in the background.
      #
      # #### Parameters
      #
      # * `identifier` - a string or symbol to identify the new process by.
      #
      # #### Returns
      #
      # * `running` - [true, false]
      #
      def running?(identifier)
        process = for_ident(identifier)
        return false unless process
        process.alive?
      end
    end

    def initialize(identifier, pid: nil, time: Time.now.strftime("%s")) # :nodoc:
      @identifier = identifier
      @pid = pid
      @time = time
      FileUtils.mkdir_p(ShopifyCli::ProcessSupervision.run_dir)
      @pid_path = File.join(ShopifyCli::ProcessSupervision.run_dir, "#{identifier}.pid")
      @log_path = File.join(ShopifyCli::ProcessSupervision.run_dir, "#{identifier}.log")
    end

    ##
    # will attempt to shutdown a running process
    #
    # #### Parameters
    #
    # * `ctx` - the context of this command
    #
    # #### Returns
    #
    # * `stopped` - [true, false]
    #
    def stop
      kill_proc
      unlink
      true
    rescue
      false
    end

    ##
    # will help identify if your process is still running in the background.
    #
    # #### Returns
    #
    # * `alive` - [true, false]
    #
    def alive?
      stat(pid)
    end

    ##
    # persists the pidfile
    #
    def write
      FileUtils.mkdir_p(File.dirname(pid_path))
      File.write(pid_path, "#{pid}:#{time}")
    end

    private

    def unlink
      File.unlink(pid_path)
      File.unlink(log_path)
      nil
    rescue Errno::ENOENT
      nil
    end

    def kill_proc
      if Context.new.windows?
        kill(pid)
      else
        kill(-pid) # process group
      end
    rescue Errno::ESRCH
      begin
        kill(pid)
      rescue Errno::ESRCH # The process group does not exist, try the pid itself
        # Race condition, process died in the middle
      end
    end

    def kill(id)
      ctx = Context.new

      # Windows does not handle SIGTERM, go straight to SIGKILL
      unless ctx.windows?
        Process.kill("TERM", id)
        50.times do
          sleep(0.1)
          break unless stat(id)
        end
      end
      Process.kill("KILL", id) if stat(id)
      sleep(0.1) if ctx.windows? # Give Windows a second to actually close the process
    end

    def stat(id)
      Process.kill(0, id) # signal 0 checks if pid is alive
      true
    rescue Errno::EPERM
      true
    rescue Errno::ESRCH
      false
    end
  end
end