require 'thread'
require 'bluepill/system'
require 'bluepill/process_journal'

module Bluepill
  class Application
    PROCESS_COMMANDS = [:start, :stop, :restart, :unmonitor, :status].freeze

    attr_accessor :name, :logger, :base_dir, :socket, :pid_file, :kill_timeout
    attr_accessor :groups, :work_queue
    attr_accessor :pids_dir, :log_file

    def initialize(name, options = {})
      self.name = name

      @foreground   = options[:foreground]
      self.log_file = options[:log_file]
      self.base_dir = ProcessJournal.base_dir = options[:base_dir] ||
        ENV['BLUEPILL_BASE_DIR'] ||
        (::Process.euid != 0 ? File.join(ENV['HOME'], '.bluepill') : '/var/run/bluepill')
      self.pid_file = File.join(base_dir, 'pids', self.name + '.pid')
      self.pids_dir = File.join(base_dir, 'pids', self.name)
      self.kill_timeout = options[:kill_timeout] || 10

      self.groups = {}

      self.logger = ProcessJournal.logger = Bluepill::Logger.new(log_file: log_file, stdout: foreground?).prefix_with(self.name)

      setup_signal_traps
      setup_pids_dir

      @mutex = Mutex.new
    end

    def foreground?
      !!@foreground
    end

    def mutex(&b)
      @mutex.synchronize(&b)
    end

    def load
      start_server
    rescue StandardError => e
      $stderr.puts('Failed to start bluepill:')
      $stderr.puts(format('%s `%s`', e.class.name, e.message))
      $stderr.puts(e.backtrace)
      exit(5)
    end

    PROCESS_COMMANDS.each do |command|
      class_eval <<-END
        def #{command}(group_name = nil, process_name = nil)
          self.send_to_process_or_group(:#{command}, group_name, process_name)
        end
      END
    end

    def add_process(process, group_name = nil)
      group_name = group_name.to_s if group_name

      groups[group_name] ||= Group.new(group_name, logger: logger.prefix_with(group_name))
      groups[group_name].add_process(process)
    end

    def version
      Bluepill::Version
    end

  protected

    def send_to_process_or_group(method, group_name, process_name)
      if group_name.nil? && process_name.nil?
        groups.values.collect do |group|
          group.send(method)
        end.flatten
      elsif groups.key?(group_name)
        groups[group_name].send(method, process_name)
      elsif process_name.nil?
        # they must be targeting just by process name
        process_name = group_name
        groups.values.collect do |group|
          group.send(method, process_name)
        end.flatten
      else
        []
      end
    end

    def start_listener
      @listener_thread.kill if @listener_thread
      @listener_thread = Thread.new do
        loop do
          begin
            client = socket.accept
            client.close_on_exec = true  if client.respond_to?(:close_on_exec=)
            command, *args = client.readline.strip.split(':')
            response = begin
              mutex { send(command, *args) }
            rescue => e
              e
            end
            client.write(Marshal.dump(response))
          rescue StandardError => e
            logger.err(format('Got exception in cmd listener: %s `%s`', e.class.name, e.message))
            e.backtrace.each { |l| logger.err(l) }
          ensure
            begin
              client.close
            rescue IOError
              # closed stream
            end
          end
        end
      end
    end

    def start_server
      kill_previous_bluepill
      ProcessJournal.kill_all_from_all_journals
      ProcessJournal.clear_all_atomic_fs_locks

      begin
        ::Process.setpgid(0, 0)
      rescue Errno::EPERM
      end

      Daemonize.daemonize unless foreground?

      logger.reopen

      $0 = "bluepilld: #{name}"

      groups.each { |_, group| group.determine_initial_state }

      write_pid_file
      self.socket = Bluepill::Socket.server(base_dir, name)
      start_listener

      run
    end

    def run
      @running = true # set to false by signal trap
      while @running
        mutex do
          System.reset_data
          groups.each { |_, group| group.tick }
        end
        sleep 1
      end
    end

    def cleanup
      ProcessJournal.kill_all_from_all_journals
      ProcessJournal.clear_all_atomic_fs_locks
      begin
        System.delete_if_exists(socket.path) if socket
      rescue IOError
      end
      System.delete_if_exists(pid_file)
    end

    def setup_signal_traps
      terminator = proc do
        puts 'Terminating...'
        cleanup
        @running = false
      end

      Signal.trap('TERM', &terminator)
      Signal.trap('INT', &terminator)

      Signal.trap('HUP') do
        logger.reopen if logger
      end
    end

    def setup_pids_dir
      FileUtils.mkdir_p(pids_dir) unless File.exist?(pids_dir)
      # we need everybody to be able to write to the pids_dir as processes managed by
      # bluepill will be writing to this dir after they've dropped privileges
      FileUtils.chmod(0777, pids_dir)
    end

    def kill_previous_bluepill
      return unless File.exist?(pid_file)
      previous_pid = File.read(pid_file).to_i
      return unless System.pid_alive?(previous_pid)
      ::Process.kill(0, previous_pid)
      puts "Killing previous bluepilld[#{previous_pid}]"
      ::Process.kill(2, previous_pid)
    rescue => e
      $stderr.puts 'Encountered error trying to kill previous bluepill:'
      $stderr.puts "#{e.class}: #{e.message}"
      exit(4) unless e.is_a?(Errno::ESRCH)
    else
      kill_timeout.times do |_i|
        sleep 0.5
        break unless System.pid_alive?(previous_pid)
      end

      if System.pid_alive?(previous_pid)
        $stderr.puts "Previous bluepilld[#{previous_pid}] didn't die"
        exit(4)
      end
    end

    def write_pid_file
      File.open(pid_file, 'w') { |x| x.write(::Process.pid) }
    end
  end
end
