require "spring/boot"
require "pty"

module Spring
  class Application
    attr_reader :manager, :watcher, :spring_env, :original_env

    def initialize(manager, original_env, spring_env = Env.new)
      @manager      = manager
      @original_env = original_env
      @spring_env   = spring_env
      @mutex        = Mutex.new
      @waiting      = {}
      @clients      = {}
      @preloaded    = false
      @state        = :initialized
      @interrupt    = IO.pipe
    end

    def state(val)
      return if exiting?
      log "#{@state} -> #{val}"
      @state = val
    end

    def state!(val)
      state val
      @interrupt.last.write "."
    end

    def spawn_env
      env = JSON.load(ENV["SPRING_SPAWN_ENV"].dup).map { |key, value| "#{key}=#{value}" }
      env.join(", ") if env.any?
    end

    def app_env
      ENV['RAILS_ENV']
    end

    def app_name
      spring_env.app_name
    end

    def log(message)
      spring_env.log "[application:#{app_env}] #{message}"
    end

    def preloaded?
      @preloaded
    end

    def preload_failed?
      @preloaded == :failure
    end

    def exiting?
      @state == :exiting
    end

    def terminating?
      @state == :terminating
    end

    def watcher_stale?
      @state == :watcher_stale
    end

    def initialized?
      @state == :initialized
    end

    def start_watcher
      @watcher = Spring.watcher

      @watcher.on_stale do
        state! :watcher_stale
      end

      if @watcher.respond_to? :on_debug
        @watcher.on_debug do |message|
          spring_env.log "[watcher:#{app_env}] #{message}"
        end
      end

      @watcher.start
    end

    def preload
      log "preloading app"

      begin
        require "spring/commands"
      ensure
        start_watcher
      end

      require Spring.application_root_path.join("config", "application")

      unless Rails.respond_to?(:gem_version) && Rails.gem_version >= Gem::Version.new('6.0.0')
        raise "Spring only supports Rails >= 6.0.0"
      end

      Rails::Application.initializer :ensure_reloading_is_enabled, group: :all do
        next if Spring.dangerously_allow_disabling_reloading

        if Rails.application.config.cache_classes
          config_name, set_to = if Rails.application.config.respond_to?(:enable_reloading=)
            ["enable_reloading", "true"]
          else
            ["cache_classes", "false"]
          end
          raise <<-MSG.strip_heredoc
            Spring reloads, and therefore needs the application to have reloading enabled.
            Please, set config.#{config_name} to #{set_to} in config/environments/#{Rails.env}.rb.
            (If you understand the trade-offs and want to disable Rails' reloader anyway,
            set `Spring.dangerously_allow_disabling_reloading = true` in config/spring.rb.)
          MSG
        end
      end

      require Spring.application_root_path.join("config", "environment")

      invoke_after_environment_load_callbacks
      preload_framework_base_classes

      disconnect_database

      @preloaded = :success
    rescue Exception => e
      @preloaded = :failure
      watcher.add e.backtrace.map { |line| line[/^(.*)\:\d+/, 1] }
      raise e unless initialized?
    ensure
      watcher.add loaded_application_features
      watcher.add Spring.gemfile, Spring.gemfile_lock

      if defined?(Rails) && Rails.application
        watcher.add Rails.application.paths["config/initializers"]
        rails_root = Rails.root.to_s
        Rails::Engine.subclasses.each do |engine|
          if engine.root.to_s.start_with?(rails_root)
            watcher.add engine.paths["config/initializers"].expanded
          end
        end
        watcher.add Rails.application.paths["config/database"]
        if secrets_path = Rails.application.paths["config/secrets"]
          watcher.add secrets_path
        end
      end
    end

    # Eagerly autoload framework base classes
    FRAMEWORK_BASE_CLASSES = %w[
      ActionMailer::Base
      ActionController::Base
      ActionController::API
    ].freeze

    def preload_framework_base_classes
      FRAMEWORK_BASE_CLASSES.each do |const|
        Object.const_get(const) if Object.const_defined?(const)
      end
    end

    def eager_preload
      with_pty do
        # we can't see stderr and there could be issues when it's overflown
        # see https://github.com/rails/spring/issues/396
        STDERR.reopen("/dev/null")
        preload
      end
    end

    def run
      state :running
      manager.puts

      loop do
        IO.select [manager, @interrupt.first]

        if terminating? || watcher_stale? || preload_failed?
          exit
        else
          serve manager.recv_io(UNIXSocket)
        end
      end
    end

    def serve(client)
      log "got client"
      manager.puts

      @clients[client] = true

      _stdout, stderr, _stdin = streams = 3.times.map { client.recv_io }
      [STDOUT, STDERR, STDIN].zip(streams).each { |a, b| a.reopen(b) }

      if preloaded?
        client.puts(0) # preload success
      else
        begin
          preload
          client.puts(0) # preload success
        rescue Exception
          log "preload failed"
          ignore_client_disconnect { client.puts(1) } # preload failure
          raise
        end
      end

      args, env = JSON.load(client.read(client.gets.to_i)).values_at("args", "env")
      command   = Spring.command(args.shift)

      connect_database
      setup command

      if Rails.application.reloaders.any?(&:updated?)
        Rails.application.reloader.reload!
      end

      pid = fork {
        # Make sure to close other clients otherwise their graceful termination
        # will be impossible due to reference from this fork.
        @clients.each_key { |c| c.close if c != client }

        Process.setsid
        IGNORE_SIGNALS.each { |sig| trap(sig, "DEFAULT") }
        trap("TERM", "DEFAULT")

        unless Spring.quiet
          STDERR.puts "Running via Spring preloader in process #{Process.pid}"

          if Rails.env.production?
            STDERR.puts "WARNING: Spring is running in production. To fix "         \
                        "this make sure the spring gem is only present "            \
                        "in `development` and `test` groups in your Gemfile "       \
                        "and make sure you always use "                             \
                        "`bundle install --without development test` in production"
          end
        end

        ARGV.replace(args)
        $0 = command.exec_name

        # Delete all env vars which are unchanged from before Spring started
        original_env.each { |k, v| ENV.delete k if ENV[k] == v }

        # Load in the current env vars, except those which *were* changed when Spring started
        env.each { |k, v| ENV[k] ||= v }

        connect_database
        srand

        invoke_after_fork_callbacks
        shush_backtraces

        command.call
      }

      disconnect_database

      log "forked #{pid}"
      manager.puts pid

      wait pid, streams, client
    rescue Exception => e
      if e.is_a?(Errno::EPIPE)
        log "client disconnected (#{e.message}), ignoring command"
      else
        log "exception: #{e}"
      end
      manager.puts unless pid

      if streams && !e.is_a?(SystemExit)
        ignore_client_disconnect { print_exception(stderr, e) }
        streams.each { |stream| ignore_client_disconnect { stream.close } }
      end

      ignore_client_disconnect { client.puts(1) if pid }
      client.close
    ensure
      # Redirect STDOUT and STDERR to prevent from keeping the original FDs
      # (i.e. to prevent `spring rake -T | grep db` from hanging forever),
      # even when exception is raised before forking (i.e. preloading).
      reset_streams
    end

    def terminate
      if exiting?
        # Ensure that we do not ignore subsequent termination attempts
        log "forced exit"
        @waiting.each_key { |pid| Process.kill("TERM", pid) }
        Kernel.exit
      else
        state! :terminating
      end
    end

    def exit
      state :exiting
      manager.shutdown(:RDWR)
      exit_if_finished
      sleep
    end

    def exit_if_finished
      @mutex.synchronize {
        Kernel.exit if exiting? && @waiting.empty?
      }
    end

    # The command might need to require some files in the
    # main process so that they are cached. For example a test command wants to
    # load the helper file once and have it cached.
    def setup(command)
      if command.setup
        watcher.add loaded_application_features # loaded features may have changed
      end
    end

    def invoke_after_fork_callbacks
      Spring.after_fork_callbacks.each do |callback|
        callback.call
      end
    end

    def invoke_after_environment_load_callbacks
      Spring.after_environment_load_callbacks.each do |callback|
        callback.call
      end
    end

    def loaded_application_features
      root = Spring.application_root_path.to_s
      $LOADED_FEATURES.select { |f| f.start_with?(root) }
    end

    def disconnect_database
      ActiveRecord::Base.remove_connection if active_record_configured?
    end

    def connect_database
      ActiveRecord::Base.establish_connection if active_record_configured?
    end

    # This feels very naughty
    def shush_backtraces
      Kernel.module_eval do
        old_raise = Kernel.method(:raise)
        remove_method :raise
        if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2.0')
          define_method :raise do |*args, **kwargs|
            begin
              old_raise.call(*args, **kwargs)
            ensure
              if $!
                lib = File.expand_path("..", __FILE__)
                $!.backtrace.reject! { |line| line.start_with?(lib) } unless $!.backtrace.frozen?
                $!.backtrace_locations.reject! { |line| line.path&.start_with?(lib) } unless $!.backtrace_locations.frozen?
              end
            end
          end
        else
          define_method :raise do |*args|
            begin
              old_raise.call(*args)
            ensure
              if $!
                lib = File.expand_path("..", __FILE__)
                $!.backtrace.reject! { |line| line.start_with?(lib) } unless $!.backtrace.frozen?
                $!.backtrace_locations.reject! { |line| line.path&.start_with?(lib) } unless $!.backtrace_locations.frozen?
              end
            end
          end
        end
        private :raise
      end
    end

    def print_exception(stream, error)
      first, rest = error.backtrace.first, error.backtrace.drop(1)
      stream.puts("#{first}: #{error} (#{error.class})")
      rest.each { |line| stream.puts("\tfrom #{line}") }
    end

    def with_pty
      PTY.open do |master, slave|
        [STDOUT, STDERR, STDIN].each { |s| s.reopen slave }
        reader_thread = Spring.failsafe_thread { master.read }
        begin
          yield
        ensure
          reader_thread.kill
          reset_streams
        end
      end
    end

    def reset_streams
      [STDOUT, STDERR].each { |stream| stream.reopen(spring_env.log_file) }
      STDIN.reopen("/dev/null")
    end

    def wait(pid, streams, client)
      @mutex.synchronize { @waiting[pid] = true }

      # Wait in a separate thread so we can run multiple commands at once
      Spring.failsafe_thread {
        begin
          _, status = Process.wait2 pid
          log "#{pid} exited with #{status.exitstatus || status.inspect}"

          streams.each(&:close)
          client.puts(status.exitstatus || status.to_i)
          client.close
        ensure
          @mutex.synchronize { @waiting.delete pid }
          exit_if_finished
        end
      }

      Spring.failsafe_thread {
        while signal = client.gets.chomp
          begin
            Process.kill(signal, -Process.getpgid(pid))
            client.puts(0)
          rescue Errno::ESRCH
            client.puts(1)
          end
        end
      }
    end

    private

    # Tolerate Errno::EPIPE on writes to the client socket. Once the client
    # disconnects, every subsequent write to it raises — that's expected
    # during `serve`'s status reporting; we'd be talking to a process
    # that's gone.
    def ignore_client_disconnect
      yield
    rescue Errno::EPIPE
    end

    def active_record_configured?
      defined?(ActiveRecord::Base) && ActiveRecord::Base.configurations.any?
    end
  end
end
