require 'etc'
require 'fileutils'

#
# A simple daemon, in a Debian style. Adapted from gem Dante.
#

module Nickserver
  class Daemon

    def self.run(name, &block)
      self.new.run(name, &block)
    end

    def run(name, &block)
      @name = name
      parse_options
      Config.load
      send("command_#{@command}", &block)
    end

    private

    MAX_WAIT = 2

    #
    # PERMISSIONS
    #

    #
    # see http://timetobleed.com/5-things-you-dont-know-about-user-ids-that-will-destroy-you/
    # (hint: it is easy to get it wrong)
    #
    def drop_permissions_to(username)
      if username != 'root'
        if Process::Sys.getuid == 0
          Process::Sys.setuid(Etc.getpwnam(username).uid)
          if root?
            bail "failed to drop permissions"
          end
        else
          bail "cannot change process uid to #{username}"
        end
      end
    end

    def root?
      begin
        Process::Sys.setuid(0)
      rescue Errno::EPERM
        false
      else
        true
      end
    end

    #
    # PROCESS STUFF
    #

    def daemonize
      return bail("Process is already started") if daemon_running?
      pid = fork do
        exit if fork
        Process.setsid
        exit if fork
        create_pid_file(Config.pid_file, Config.user)
        catch_signals
        redirect_output
        drop_permissions_to(Config.user) if Config.user
        File.umask 0000
        yield
      end
    end

    def create_pid_file(file, user)
      File.open file, 'w' do |f|
        f.write("#{Process.pid}\n")
      end
      FileUtils.chown(user, nil, file) if Process::Sys.getuid == 0
    rescue Errno::EACCES
      bail "insufficient permission to create to pid file `#{file}`"
    rescue Errno::ENOENT
      bail "bad path for pid file `#{file}`"
    rescue Errno::EROFS
      bail "can't create pid file `#{file}` on read-only filesystem"
    end

    def daemon_running?
      return false unless File.exist?(Config.pid_file)
      Process.kill 0, File.read(Config.pid_file).to_i
      true
    rescue Errno::ESRCH
      false
    end

    def pid_from_file(file)
      pid = IO.read(file).chomp
      if pid != ""
        pid.to_i
      else
        nil
      end
    end

    def kill_pid
      file = Config.pid_file
      if File.exist?(file)
        pid = pid_from_file(file)
        if pid
          Process.kill('TERM', pid)
          puts "Stopped #{@name} process #{pid}."
        else
          bail "Error reading pid file #{file}"
        end
        remove_pid_file
      else
        bail "could not find pid file #{file}"
      end
    rescue => e
      puts "Failed to stop: #{e}"
    end

    def remove_pid_file
      FileUtils.rm Config.pid_file
    rescue Errno::EACCES
      bail 'insufficient permission to remove pid file'
    end

    #
    # stop when we should
    #
    def catch_signals
      ["SIGTERM", "SIGINT", "SIGHUP"].each do |signal|
        Signal.trap(signal) {
          exit
        }
      end
    end

    #
    # OUTPUT
    #

    def usage(msg)
      puts msg
      puts
      puts "Usage: #{@name} [OPTION] COMMAND"
      puts "COMMAND is one of: start, stop, restart, status, version, foreground"
      puts "OPTION is one of: --verbose"
      puts
      exit 1
    end

    def bail(msg)
      puts "#{@name.capitalize} ERROR: #{msg}."
      puts "Bailing out."
      exit(1)
    end

    #
    # Redirect output based on log settings (reopens stdout/stderr to specified logfile)
    # If log_path is nil, redirect to /dev/null to quiet output
    #
    def redirect_output
      if log_path = Config.log_file
        FileUtils.mkdir_p File.dirname(log_path), mode: 0755
        FileUtils.touch log_path
        File.chmod(0600, log_path)
        if Config.user && Process::Sys.getuid == 0
          FileUtils.chown(Config.user, nil, log_path)
        end
        $stdout.reopen(log_path, 'a')
        $stderr.reopen $stdout
        $stdout.sync = true
      else
        # redirect to /dev/null
        $stdin.reopen '/dev/null'
        $stdout.reopen '/dev/null', 'a'
        $stderr.reopen $stdout
      end
    rescue Errno::EACCES
      bail "no permission to create log file #{log_path}"
    end

    #
    # UTILITY
    #

    #
    # Runs until the block condition is met or the timeout_seconds is exceeded
    # until_true(10) { ...return_condition... }
    #
    def until_true(timeout_seconds=MAX_WAIT, &block)
      elapsed_seconds = 0
      interval = 0.5
      while elapsed_seconds < timeout_seconds && block.call != true
        elapsed_seconds += interval
        sleep(interval)
      end
      elapsed_seconds < timeout_seconds
    end

    def parse_options
      loop do
        case ARGV[0]
          when 'start'      then ARGV.shift; @command = :start
          when 'stop'       then ARGV.shift; @command = :stop
          when 'restart'    then ARGV.shift; @command = :restart
          when 'status'     then ARGV.shift; @command = :status
          when 'version'    then ARGV.shift; @command = :version
          when 'foreground' then ARGV.shift; @command = :foreground
          when '--verbose'  then ARGV.shift; Config.verbose = true
          when /^-/         then override_default_config(ARGV.shift, ARGV.shift)
          else break
        end
      end
      usage("Missing command") unless @command
    end

    def override_default_config(flag, value)
      flag = flag.sub /^--/, ''
      if Config.respond_to?("#{flag}=")
        Config.send("#{flag}=", value)
      else
        usage("Unknown option: --#{flag}")
      end
    end

    #
    # COMMANDS
    #

    def command_version
      puts "nickserver #{Nickserver::VERSION}, ruby #{RUBY_VERSION}"
      exit(0)
    end

    def command_start(&block)
      daemonize(&block)
      if until_true { daemon_running? }
        puts "#{@name.capitalize} started successfully."
        exit(0)
      else # Failed to start
        puts "#{@name.capitalize} couldn't be started."
        exit(1)
      end
    end

    def command_foreground(&block)
      trap("INT") do
        puts "\nShutting down..."
        exit(0)
      end
      yield
      exit(0)
    end

    def command_stop
      if daemon_running?
        kill_pid
        until_true { !daemon_running? }
      else
        puts "No #{@name} processes are running."
      end
    end

    def command_restart(&block)
      command_stop
      sleep(0.5)
      command_start(&block)
    end

    def command_status
      if daemon_running?
        puts "#{@name.capitalize} running, process id #{pid_from_file(Config.pid_file)}."
        exit(0)
      else
        puts "No #{@name} processes are running."
        exit(1) # must exit non-zero if not running
      end
    end

  end
end