# frozen_string_literal: true

module God
  class Process
    WRITES_PID = [:start, :restart].freeze

    attr_accessor :name, :uid, :gid, :log, :log_cmd, :err_log, :err_log_cmd,
                  :start, :stop, :restart, :unix_socket, :chroot, :env, :dir,
                  :stop_timeout, :stop_signal, :umask

    def initialize
      self.log = '/dev/null'

      @pid_file = nil
      @tracking_pid = true
      @user_log = false
      @pid = nil
      @unix_socket = nil
      @log_cmd = nil
      @stop_timeout = God::STOP_TIMEOUT_DEFAULT
      @stop_signal = God::STOP_SIGNAL_DEFAULT
    end

    def alive?
      if pid
        System::Process.new(pid).exists?
      else
        false
      end
    end

    def file_writable?(file)
      pid = fork do
        begin
          if uid
            user_method = uid.is_a?(Integer) ? :getpwuid : :getpwnam
            uid_num = Etc.send(user_method, uid).uid
            gid_num = Etc.send(user_method, uid).gid
          end
          if gid
            group_method = gid.is_a?(Integer) ? :getgrgid : :getgrnam
            gid_num = Etc.send(group_method, gid).gid
          end

          ::Dir.chroot(chroot) if chroot
          ::Process.groups = [gid_num] if gid_num
          ::Process.initgroups(uid, gid_num) if uid && gid_num
          ::Process::Sys.setgid(gid_num) if gid_num
          ::Process::Sys.setuid(uid_num) if uid
        rescue ArgumentError, Errno::EPERM, Errno::ENOENT
          exit(1)
        end

        File.writable?(file_in_chroot(file)) ? exit!(0) : exit!(1)
      end

      _wpid, status = ::Process.waitpid2(pid)
      status.exitstatus == 0
    end

    def valid?
      # determine if we're tracking pid or not
      pid_file

      valid = true

      # a start command must be specified
      if start.nil?
        valid = false
        applog(self, :error, 'No start command was specified')
      end

      # uid must exist if specified
      if uid
        begin
          Etc.getpwnam(uid)
        rescue ArgumentError
          valid = false
          applog(self, :error, "UID for '#{uid}' does not exist")
        end
      end

      # gid must exist if specified
      if gid
        begin
          Etc.getgrnam(gid)
        rescue ArgumentError
          valid = false
          applog(self, :error, "GID for '#{gid}' does not exist")
        end
      end

      # dir must exist and be a directory if specified
      if dir
        if !File.exist?(dir)
          valid = false
          applog(self, :error, "Specified directory '#{dir}' does not exist")
        elsif !File.directory?(dir)
          valid = false
          applog(self, :error, "Specified directory '#{dir}' is not a directory")
        end
      end

      # pid dir must exist if specified
      if !@tracking_pid && !File.exist?(File.dirname(pid_file))
        valid = false
        applog(self, :error, "PID file directory '#{File.dirname(pid_file)}' does not exist")
      end

      # pid dir must be writable if specified
      if !@tracking_pid && File.exist?(File.dirname(pid_file)) && !file_writable?(File.dirname(pid_file))
        valid = false
        applog(self, :error, "PID file directory '#{File.dirname(pid_file)}' is not writable by #{uid || Etc.getlogin}")
      end

      # log dir must exist
      unless File.exist?(File.dirname(log))
        valid = false
        applog(self, :error, "Log directory '#{File.dirname(log)}' does not exist")
      end

      # log file or dir must be writable
      if File.exist?(log)
        unless file_writable?(log)
          valid = false
          applog(self, :error, "Log file '#{log}' exists but is not writable by #{uid || Etc.getlogin}")
        end
      else
        unless file_writable?(File.dirname(log))
          valid = false
          applog(self, :error, "Log directory '#{File.dirname(log)}' is not writable by #{uid || Etc.getlogin}")
        end
      end

      # chroot directory must exist and have /dev/null in it
      if chroot
        unless File.directory?(chroot)
          valid = false
          applog(self, :error, "CHROOT directory '#{chroot}' does not exist")
        end

        unless File.exist?(File.join(chroot, '/dev/null'))
          valid = false
          applog(self, :error, "CHROOT directory '#{chroot}' does not contain '/dev/null'")
        end
      end

      valid
    end

    # DON'T USE THIS INTERNALLY. Use the instance variable. -- Kev
    # No really, trust me. Use the instance variable.
    def pid_file=(value)
      # if value is nil, do the right thing
      @tracking_pid = if value
                        false
                      else
                        true
                      end

      @pid_file = value
    end

    def pid_file
      @pid_file ||= default_pid_file
    end

    # Fetch the PID from pid_file. If the pid_file does not
    # exist, then use the PID from the last time it was read.
    # If it has never been read, then return nil.
    #
    # Returns Integer(pid) or nil
    def pid
      contents = File.read(pid_file).strip rescue ''
      real_pid = /^\d+$/.match?(contents) ? contents.to_i : nil

      if real_pid
        @pid = real_pid
        real_pid
      else
        @pid
      end
    end

    # Send the given signal to this process.
    #
    # Returns nothing
    def signal(sig)
      sig = sig.to_i if sig.to_i != 0
      applog(self, :info, "#{name} sending signal '#{sig}' to pid #{pid}")
      ::Process.kill(sig, pid) rescue nil
    end

    def start!
      call_action(:start)
    end

    def stop!
      call_action(:stop)
    end

    def restart!
      call_action(:restart)
    end

    def default_pid_file
      File.join(God.pid_file_directory, "#{name}.pid")
    end

    def call_action(action)
      command = send(action)

      if action == :stop && command.nil?
        pid = self.pid
        command = lambda do
          applog(self, :info, "#{name} stop: default lambda killer")

          ::Process.kill(@stop_signal, pid) rescue nil
          applog(self, :info, "#{name} sent SIG#{@stop_signal}")

          # Poll to see if it's dead
          pid_not_found = false
          @stop_timeout.times do
            if pid
              begin
                ::Process.kill(0, pid)
              rescue Errno::ESRCH
                # It died. Good.
                applog(self, :info, "#{name} process stopped")
                return
              end
            else
              applog(self, :warn, "#{name} pid not found in #{pid_file}") unless pid_not_found
              pid_not_found = true
            end

            sleep 1
          end

          ::Process.kill('KILL', pid) rescue nil
          applog(self, :warn, "#{name} still alive after #{@stop_timeout}s; sent SIGKILL")
        end
      end

      case command
      when String
        if [:start, :restart].include?(action) && @tracking_pid
          # double fork god-daemonized processes
          # we don't want to wait for them to finish
          r, w = IO.pipe
          begin
            opid = fork do
              $stdout.reopen(w)
              r.close
              pid = self.spawn(command)
              puts pid # send pid back to forker
              exit!(0)
            end

            ::Process.waitpid(opid, 0)
            w.close
            pid = r.gets.chomp
          ensure
            # make sure the file descriptors get closed no matter what
            r.close rescue nil
            w.close rescue nil
          end
        else
          # single fork self-daemonizing processes
          # we want to wait for them to finish
          pid = self.spawn(command)
          status = ::Process.waitpid2(pid, 0)
          exit_code = status[1] >> 8

          applog(self, :warn, "#{name} #{action} command exited with non-zero code = #{exit_code}") if exit_code != 0

          ensure_stop if action == :stop
        end

        if @tracking_pid || (@pid_file.nil? && WRITES_PID.include?(action))
          File.write(default_pid_file, pid)

          @tracking_pid = true
          @pid_file = default_pid_file
        end
      when Proc
        # lambda command
        command.call
      else
        raise NotImplementedError
      end
    end

    # Fork/exec the given command, returns immediately
    #   +command+ is the String containing the shell command
    #
    # Returns nothing
    def spawn(command)
      fork do
        File.umask umask if umask
        uid_num = Etc.getpwnam(uid).uid if uid
        gid_num = Etc.getgrnam(gid).gid if gid
        gid_num = Etc.getpwnam(uid).gid if gid.nil? && uid

        ::Dir.chroot(chroot) if chroot
        ::Process.setsid
        ::Process.groups = [gid_num] if gid_num
        ::Process.initgroups(uid, gid_num) if uid && gid_num
        ::Process::Sys.setgid(gid_num) if gid_num
        ::Process::Sys.setuid(uid_num) if uid
        self.dir ||= '/'
        Dir.chdir self.dir
        $0 = command
        $stdin.reopen '/dev/null'
        if log_cmd
          $stdout.reopen IO.popen(log_cmd, 'a')
        else
          $stdout.reopen file_in_chroot(log), 'a'
        end
        if err_log_cmd
          $stderr.reopen IO.popen(err_log_cmd, 'a')
        elsif err_log && (log_cmd || err_log != log)
          $stderr.reopen file_in_chroot(err_log), 'a'
        else
          $stderr.reopen $stdout
        end

        # close any other file descriptors
        3.upto(256) { |fd| IO.new(fd).close rescue nil }

        if env.is_a?(Hash)
          env.each do |(key, value)|
            ENV[key] = value.to_s
          end
        end

        exec command unless command.empty?
      end
    end

    # Ensure that a stop command actually stops the process. Force kill
    # if necessary.
    #
    # Returns nothing
    def ensure_stop
      applog(self, :warn, "#{name} ensuring stop...")

      unless pid
        applog(self, :warn, "#{name} stop called but pid is uknown")
        return
      end

      # Poll to see if it's dead
      @stop_timeout.times do
        begin
          ::Process.kill(0, pid)
        rescue Errno::ESRCH
          # It died. Good.
          return
        end

        sleep 1
      end

      # last resort
      ::Process.kill('KILL', pid) rescue nil
      applog(self, :warn, "#{name} still alive after #{@stop_timeout}s; sent SIGKILL")
    end

    private

    def file_in_chroot(file)
      return file unless chroot

      file.gsub(/^#{Regexp.escape(File.expand_path(chroot))}/, '')
    end
  end
end