require "bundler/cli"
require "bundler/cli/common"
require "appsignal/cli"

describe Appsignal::CLI::Diagnose, :api_stub => true, :send_report => :yes_cli_input do
  include CLIHelpers

  class DiagnosticsReportEndpoint
    class << self
      attr_reader :received_report

      def clear_report!
        @received_report = nil
      end

      def call(env)
        @received_report = JSON.parse(env["rack.input"].read)["diagnose"]
        [200, {}, [JSON.generate(:token => "my_support_token")]]
      end
    end
  end

  describe ".run" do
    let(:out_stream) { std_stream }
    let(:output) { out_stream.read }
    let(:config) { project_fixture_config }
    let(:cli) { described_class }
    let(:options) { { :environment => config.env } }
    let(:gem_path) { Bundler::CLI::Common.select_spec("appsignal").full_gem_path.strip }
    let(:received_report) { DiagnosticsReportEndpoint.received_report }
    let(:process_user) { Etc.getpwuid(Process.uid).name }
    let(:process_group) { Etc.getgrgid(Process.gid).name }
    before(:context) { Appsignal.stop }
    before do
      # Clear previous reports
      DiagnosticsReportEndpoint.clear_report!
      if cli.instance_variable_defined? :@data
        # Because this is saved on the class rather than an instance of the
        # class we need to clear it like this in case a certain test doesn't
        # generate a report.
        cli.remove_instance_variable :@data
      end

      if DependencyHelper.rails_present?
        allow(Rails).to receive(:root).and_return(Pathname.new(config.root_path))
      end
    end
    around do |example|
      original_stdin = $stdin
      $stdin = StringIO.new
      example.run
      $stdin = original_stdin
    end
    before :api_stub => true do
      stub_api_request config, "auth"
    end
    before(:send_report => :yes_cli_input) do
      accept_prompt_to_send_diagnostics_report
      capture_diagnatics_report_request
    end
    before(:send_report => :no_cli_input) { dont_accept_prompt_to_send_diagnostics_report }
    before(:send_report => :yes_cli_option) do
      options[:send_report] = true
      capture_diagnatics_report_request
    end
    before(:send_report => :no_cli_option) { options[:send_report] = false }
    after { Appsignal.config = nil }

    def capture_diagnatics_report_request
      stub_diagnostics_report_request.to_rack(DiagnosticsReportEndpoint)
    end

    def run
      run_within_dir project_fixture_path
    end

    def run_within_dir(chdir)
      prepare_cli_input
      Dir.chdir chdir do
        capture_stdout(out_stream) { cli.run(options) }
      end
    end

    def stub_diagnostics_report_request
      stub_request(:post, "https://appsignal.com/diag").with(
        :query => {
          :api_key => config[:push_api_key],
          :environment => config.env,
          :gem_version => Appsignal::VERSION,
          :hostname => config[:hostname],
          :name => config[:name]
        },
        :headers => { "Content-Type" => "application/json; charset=UTF-8" }
      )
    end

    def accept_prompt_to_send_diagnostics_report
      add_cli_input "y"
    end

    def dont_accept_prompt_to_send_diagnostics_report
      add_cli_input "n"
    end

    it "outputs header and support text" do
      run
      expect(output).to include \
        "AppSignal diagnose",
        "https://docs.appsignal.com/",
        "support@appsignal.com"
    end

    it "logs to the log file" do
      run
      log_contents = File.read(config.log_file_path)
      expect(log_contents).to contains_log :info, "Starting AppSignal diagnose"
    end

    describe "report" do
      context "when user wants to send report" do
        it "sends report" do
          run
          expect(output).to include "Diagnostics report",
            "Send diagnostics report to AppSignal? (Y/n): "
        end

        it "outputs the support token from the server" do
          run
          expect(output).to include "Your support token: my_support_token"
          expect(output).to include "View this report:   https://appsignal.com/diagnose/my_support_token"
        end

        context "when server response is invalid" do
          before do
            stub_diagnostics_report_request
              .to_return(:status => 200, :body => %({ foo: "Invalid json", a: }))
            run
          end

          it "outputs the server response in full" do
            expect(output).to include "Error: Couldn't decode server response.",
              %({ foo: "Invalid json", a: })
          end
        end

        context "when server returns an error" do
          before do
            stub_diagnostics_report_request
              .to_return(:status => 500, :body => "report: server error")
            run
          end

          it "outputs the server response in full" do
            expect(output).to include "report: server error"
          end
        end
      end

      context "when user uses the --send-report option", :send_report => :yes_cli_option do
        it "sends the report without prompting" do
          run
          expect(output).to include "Diagnostics report",
            "Confirmed sending report using --send-report option.",
            "Transmitting diagnostics report"
        end
      end

      context "when user uses the --no-send-report option", :send_report => :no_cli_option do
        it "does not send the report" do
          run
          expect(output).to include "Diagnostics report",
            "Not sending report. (Specified with the --no-send-report option.)",
            "Not sending diagnostics information to AppSignal."
        end
      end

      context "when user doesn't want to send report", :send_report => :no_cli_input do
        it "does not send report" do
          run
          expect(output).to include "Diagnostics report",
            "Send diagnostics report to AppSignal? (Y/n): ",
            "Not sending diagnostics information to AppSignal."
        end
      end
    end

    describe "agent information" do
      before { run }

      it "outputs version numbers" do
        expect(output).to include \
          "Gem version: #{Appsignal::VERSION}",
          "Agent version: #{Appsignal::Extension.agent_version}"
      end

      it "transmits version numbers in report" do
        expect(received_report).to include(
          "library" => {
            "language" => "ruby",
            "package_version" => Appsignal::VERSION,
            "agent_version" => Appsignal::Extension.agent_version,
            "extension_loaded" => true
          }
        )
      end

      context "with extension" do
        before { run }

        it "outputs extension is loaded" do
          expect(output).to include "Extension loaded: true"
        end

        it "transmits extension_loaded: true in report" do
          expect(received_report["library"]["extension_loaded"]).to eq(true)
        end
      end

      context "without extension" do
        before do
          # When the extension isn't loaded the Appsignal.start operation exits
          # early and doesn't load the configuration.
          # Happens when the extension wasn't installed properly.
          Appsignal.extension_loaded = false
          run
        end
        after { Appsignal.extension_loaded = true }

        it "outputs extension is not loaded" do
          expect(output).to include "Extension loaded: false"
        end

        it "transmits extension_loaded: false in report" do
          expect(received_report["library"]["extension_loaded"]).to eq(false)
        end
      end
    end

    describe "installation report" do
      let(:rbconfig) { RbConfig::CONFIG }

      it "adds the installation report to the diagnostics report" do
        run
        jruby = DependencyHelper.running_jruby?
        expect(received_report["installation"]).to match(
          "result" => {
            "status" => "success"
          },
          "language" => {
            "name" => "ruby",
            "version" => "#{rbconfig["ruby_version"]}-p#{rbconfig["PATCHLEVEL"]}",
            "implementation" => jruby ? "jruby" : "ruby"
          },
          "download" => {
            "download_url" => kind_of(String),
            "checksum" => "verified"
          },
          "build" => {
            "time" => kind_of(String),
            "package_path" => File.expand_path("../../../../../", __FILE__),
            "architecture" => rbconfig["host_cpu"],
            "target" => Appsignal::System.agent_platform,
            "musl_override" => false,
            "library_type" => jruby ? "dynamic" : "static",
            "source" => "remote",
            "dependencies" => kind_of(Hash),
            "flags" => kind_of(Hash),
            "agent_version" => Appsignal::Extension.agent_version
          },
          "host" => {
            "root_user" => false,
            "dependencies" => kind_of(Hash)
          }
        )
      end

      it "prints the extension installation report" do
        run
        jruby = Appsignal::System.jruby?
        expect(output).to include(
          "Extension installation report",
          "Language details",
          "  Implementation: #{jruby ? "jruby" : "ruby"}",
          "  Ruby version: #{"#{rbconfig["ruby_version"]}-p#{rbconfig["PATCHLEVEL"]}"}",
          "Download details",
          "  Download URL: https://",
          "  Checksum: verified",
          "Build details",
          "  Install time: 20",
          "  Architecture: #{rbconfig["host_cpu"]}",
          "  Target: #{Appsignal::System.agent_platform}",
          "  Musl override: false",
          "  Library type: #{jruby ? "dynamic" : "static"}",
          "  Dependencies: {",
          "  Flags: {",
          "Host details",
          "  Root user: false",
          "  Dependencies: {"
        )
      end

      context "without install report" do
        let(:error) { RuntimeError.new("foo") }
        before do
          allow(File).to receive(:read).and_call_original
          expect(File).to receive(:read)
            .with(File.expand_path("../../../../../ext/install.report", __FILE__))
            .and_raise(error)
        end

        it "sends an error" do
          run
          expect(received_report["installation"]).to match(
            "parsing_error" => {
              "error" => "RuntimeError: foo",
              "backtrace" => error.backtrace
            }
          )
        end

        it "prints the error" do
          run
          expect(output).to include(
            "Extension installation report",
            "  Error found while parsing the report.",
            "  Error: RuntimeError: foo"
          )
          expect(output).to_not include("Raw report:")
        end
      end

      context "when report is invalid YAML" do
        let(:raw_report) { "foo:\nbar" }
        before do
          allow(File).to receive(:read).and_call_original
          expect(File).to receive(:read)
            .with(File.expand_path("../../../../../ext/install.report", __FILE__))
            .and_return(raw_report)
        end

        it "sends an error" do
          run
          expect(received_report["installation"]).to match(
            "parsing_error" => {
              "error" => kind_of(String),
              "backtrace" => kind_of(Array),
              "raw" => raw_report
            }
          )
        end

        it "prints the error" do
          run
          expect(output).to include(
            "Extension installation report",
            "  Error found while parsing the report.",
            "  Error: ",
            "  Raw report:\n#{raw_report}"
          )
        end
      end
    end

    describe "agent diagnostics" do
      let(:working_directory_stat) { File.stat("/tmp/appsignal") }

      it "starts the agent in diagnose mode and outputs the report" do
        run
        working_directory_stat = File.stat("/tmp/appsignal")
        expect_valid_agent_diagnostics_report(output, working_directory_stat)
      end

      it "adds the agent diagnostics to the report" do
        run
        expect(received_report["agent"]).to eq(
          "extension" => {
            "config" => { "valid" => { "result" => true } }
          },
          "agent" => {
            "boot" => { "started" => { "result" => true } },
            "host" => {
              "uid" => { "result" => Process.uid },
              "gid" => { "result" => Process.gid }
            },
            "config" => { "valid" => { "result" => true } },
            "logger" => { "started" => { "result" => true } },
            "working_directory_stat" => {
              "uid" => { "result" => working_directory_stat.uid },
              "gid" => { "result" => working_directory_stat.gid },
              "mode" => { "result" => working_directory_stat.mode }
            },
            "lock_path" => { "created" => { "result" => true } }
          }
        )
      end

      context "when user config has active: false" do
        before do
          # ENV is leading so easiest to set in test to force user config with active: false
          ENV["APPSIGNAL_ACTIVE"] = "false"
        end

        it "force starts the agent in diagnose mode and outputs a log" do
          run
          expect(output).to include("active: false")
          expect_valid_agent_diagnostics_report(output, working_directory_stat)
        end
      end

      context "when the extention returns invalid JSON" do
        before do
          expect(Appsignal::Extension).to receive(:diagnose).and_return("invalid agent\njson")
          run
        end

        it "prints a JSON parse error and prints the returned value" do
          expect(output).to include \
            "Agent diagnostics",
            "  Error while parsing agent diagnostics report:",
            "    Output: invalid agent\njson"
          expect(output).to match(/Error:( \d+:)? unexpected token at 'invalid agent\njson'/)
        end

        it "adds the output to the report" do
          expect(received_report["agent"]["error"])
            .to match(/unexpected token at 'invalid agent\njson'/)
          expect(received_report["agent"]["output"]).to eq(["invalid agent", "json"])
        end
      end

      context "when the extension is not loaded" do
        before do
          DiagnosticsReportEndpoint.clear_report!
          expect(Appsignal).to receive(:extension_loaded?).and_return(false)
          run
        end

        it "prints a warning" do
          expect(output).to include \
            "Agent diagnostics",
            "  Extension is not loaded. No agent report created."
        end

        it "adds the output to the report" do
          expect(received_report["agent"]).to be_nil
        end
      end

      context "when the report contains an error" do
        let(:agent_report) do
          { "error" => "fatal error" }
        end
        before do
          expect(Appsignal::Extension).to receive(:diagnose).and_return(JSON.generate(agent_report))
          run
        end

        it "prints an error for the entire report" do
          expect(output).to include "Agent diagnostics\n  Error: fatal error"
        end

        it "adds the error to the report" do
          expect(received_report["agent"]).to eq(agent_report)
        end
      end

      context "when the report is incomplete (agent failed to start)" do
        let(:agent_report) do
          {
            "extension" => {
              "config" => { "valid" => { "result" => false } }
            }
            # missing agent section
          }
        end
        before do
          expect(Appsignal::Extension).to receive(:diagnose).and_return(JSON.generate(agent_report))
          run
        end

        it "prints the tests, but shows a dash `-` for missed results" do
          expect(output).to include \
            "Agent diagnostics",
            "  Extension tests\n    Configuration: invalid",
            "  Agent tests",
            "    Started: -",
            "    Configuration: -",
            "    Logger: -",
            "    Lock path: -"
        end

        it "adds the output to the report" do
          expect(received_report["agent"]).to eq(agent_report)
        end
      end

      context "when a test contains an error" do
        let(:agent_report) do
          {
            "extension" => {
              "config" => { "valid" => { "result" => true } }
            },
            "agent" => {
              "boot" => {
                "started" => { "result" => false, "error" => "some-error" }
              }
            }
          }
        end
        before do
          expect(Appsignal::Extension).to receive(:diagnose).and_return(JSON.generate(agent_report))
          run
        end

        it "prints the error and output" do
          expect(output).to include \
            "Agent diagnostics",
            "  Extension tests\n    Configuration: valid",
            "  Agent tests\n    Started: not started\n      Error: some-error"
        end

        it "adds the agent report to the diagnostics report" do
          expect(received_report["agent"]).to eq(agent_report)
        end
      end

      context "when a test contains command output" do
        let(:agent_report) do
          {
            "extension" => {
              "config" => { "valid" => { "result" => true } }
            },
            "agent" => {
              "config" => { "valid" => { "result" => false, "output" => "some output" } }
            }
          }
        end

        it "prints the command output" do
          expect(Appsignal::Extension).to receive(:diagnose).and_return(JSON.generate(agent_report))
          run
          expect(output).to include \
            "Agent diagnostics",
            "  Extension tests\n    Configuration: valid",
            "    Configuration: invalid\n      Output: some output"
        end
      end
    end

    describe "host information" do
      let(:rbconfig) { RbConfig::CONFIG }
      let(:language_version) { "#{rbconfig["ruby_version"]}-p#{rbconfig["PATCHLEVEL"]}" }

      it "outputs host information" do
        run
        expect(output).to include \
          "Host information",
          "Architecture: #{rbconfig["host_cpu"]}",
          "Operating System: #{rbconfig["host_os"]}",
          "Ruby version: #{language_version}"
      end

      context "when on Microsoft Windows" do
        before do
          expect(RbConfig::CONFIG).to receive(:[]).with("host_os").and_return("mingw32")
          expect(RbConfig::CONFIG).to receive(:[]).at_least(:once).and_call_original
          expect(Gem).to receive(:win_platform?).and_return(true)
          run
        end

        it "adds the arch to the report" do
          expect(received_report["host"]["os"]).to eq("mingw32")
        end

        it "prints warning that Microsoft Windows is not supported" do
          expect(output).to match(/Operating System: .+ \(Microsoft Windows is not supported\.\)/)
        end
      end

      it "transmits host information in report" do
        run
        host_report = received_report["host"]
        host_report.delete("running_in_container") # Tested elsewhere
        expect(host_report).to eq(
          "architecture" => rbconfig["host_cpu"],
          "os" => rbconfig["host_os"],
          "language_version" => language_version,
          "heroku" => false,
          "root" => false
        )
      end

      describe "root user detection" do
        context "when not root user" do
          it "outputs false" do
            run
            expect(output).to include "Root user: false"
          end

          it "transmits root: false in report" do
            run
            expect(received_report["host"]["root"]).to eq(false)
          end
        end

        context "when root user" do
          before do
            allow(Process).to receive(:uid).and_return(0)
            run
          end

          it "outputs true, with warning" do
            expect(output).to include "Root user: true (not recommended)"
          end

          it "transmits root: true in report" do
            expect(received_report["host"]["root"]).to eq(true)
          end
        end
      end

      describe "Heroku detection" do
        context "when not on Heroku" do
          before { run }

          it "does not output Heroku detection" do
            expect(output).to_not include("Heroku:")
          end

          it "transmits heroku: false in report" do
            expect(received_report["host"]["heroku"]).to eq(false)
          end
        end

        context "when on Heroku" do
          before { recognize_as_heroku { run } }

          it "outputs Heroku detection" do
            expect(output).to include("Heroku: true")
          end

          it "transmits heroku: true in report" do
            expect(received_report["host"]["heroku"]).to eq(true)
          end
        end
      end

      describe "container detection" do
        context "when not in container" do
          before do
            allow(Appsignal::Extension).to receive(:running_in_container?).and_return(false)
            run
          end

          it "outputs: false" do
            expect(output).to include("Running in container: false")
          end

          it "transmits running_in_container: false in report" do
            expect(received_report["host"]["running_in_container"]).to eq(false)
          end
        end

        context "when in container" do
          before do
            allow(Appsignal::Extension).to receive(:running_in_container?).and_return(true)
            run
          end

          it "outputs: true" do
            expect(output).to include("Running in container: true")
          end

          it "transmits running_in_container: true in report" do
            expect(received_report["host"]["running_in_container"]).to eq(true)
          end
        end
      end
    end

    describe "configuration" do
      context "without environment" do
        let(:config) { project_fixture_config(nil) }
        let(:options) { {} }
        before do
          ENV.delete("RAILS_ENV") # From spec_helper
          ENV.delete("RACK_ENV")
          run_within_dir tmp_dir
        end

        it "outputs a warning that no config is loaded" do
          expect(output).to include \
            "Environment: \"\"\n",
            "    Warning: No environment set, no config loaded!",
            "  appsignal diagnose --environment=production"
        end

        it "outputs config defaults" do
          expect(output).to include("Configuration")
          expect_config_to_be_printed(Appsignal::Config::DEFAULT_CONFIG)
        end

        it "transmits validation in report" do
          default_config = hash_with_string_keys(Appsignal::Config::DEFAULT_CONFIG)
          expect(received_report["config"]).to eq(
            "options" => default_config.merge("env" => ""),
            "sources" => {
              "default" => default_config,
              "system" => {},
              "initial" => { "env" => "" },
              "file" => {},
              "env" => {}
            }
          )
        end
      end

      context "with configured environment" do
        describe "environment" do
          it "outputs environment" do
            run
            expect(output).to include(%(Environment: "production"))
          end

          context "when the source is a single source" do
            before { run }

            it "outputs the label source after the value" do
              expect(output).to include(
                %(Environment: "#{Appsignal.config.env}" (Loaded from: initial)\n)
              )
            end
          end

          context "when the source is multiple sources" do
            let(:options) { { :environment => "development" } }
            before do
              ENV["APPSIGNAL_APP_ENV"] = "production"
              config.instance_variable_set(:@env, ENV["APPSIGNAL_APP_ENV"])
              stub_api_request(config, "auth").to_return(:status => 200)
              capture_diagnatics_report_request
              run
            end

            it "outputs a list of sources with their values" do
              expect(output).to include(
                %(  Environment: "production"\n) +
                %(    Sources:\n) +
                %(      initial: "development"\n) +
                %(      env:     "production"\n)
              )
            end
          end
        end

        it "outputs configuration" do
          run
          expect(output).to include("Configuration")
          expect_config_to_be_printed(Appsignal.config.config_hash)
        end

        describe "option sources" do
          context "when the source is a single source" do
            before { run }

            it "outputs the label source after the value" do
              expect(output).to include(
                %(push_api_key: "#{Appsignal.config[:push_api_key]}" (Loaded from: file)\n)
              )
            end

            context "when the source is only default" do
              it "does not print a source" do
                expect(output).to include("debug: #{Appsignal.config[:debug]}\n")
              end
            end
          end

          context "when the source is multiple sources" do
            before do
              ENV["APPSIGNAL_APP_NAME"] = "MyApp"
              config[:name] = ENV["APPSIGNAL_APP_NAME"]
              stub_api_request(config, "auth").to_return(:status => 200)
              capture_diagnatics_report_request
              run
            end

            if DependencyHelper.rails_present?
              it "outputs a list of sources with their values" do
                expect(output).to include(
                  %(  name: "MyApp"\n) +
                  %(    Sources:\n) +
                  %(      initial: "MyApp"\n) +
                  %(      file:    "TestApp"\n) +
                  %(      env:     "MyApp"\n)
                )
              end
            else
              it "outputs a list of sources with their values" do
                expect(output).to include(
                  %(  name: "MyApp"\n) +
                  %(    Sources:\n) +
                  %(      file: "TestApp"\n) +
                  %(      env:  "MyApp"\n)
                )
              end
            end
          end
        end

        it "transmits config in report" do
          run
          additional_initial_config = {}
          if DependencyHelper.rails_present?
            additional_initial_config = {
              :name => "MyApp",
              :log_path => File.join(Rails.root, "log")
            }
          end
          final_config = { "env" => "production" }
            .merge(additional_initial_config)
            .merge(config.config_hash)
          expect(received_report["config"]).to match(
            "options" => hash_with_string_keys(final_config),
            "sources" => {
              "default" => hash_with_string_keys(Appsignal::Config::DEFAULT_CONFIG),
              "system" => {},
              "initial" => hash_with_string_keys(config.initial_config.merge(additional_initial_config)),
              "file" => hash_with_string_keys(config.file_config),
              "env" => {}
            }
          )
        end
      end

      context "with unconfigured environment" do
        let(:config) { project_fixture_config("foobar") }
        before { run_within_dir tmp_dir }

        it "outputs environment" do
          expect(output).to include(%(Environment: "foobar"))
        end

        it "outputs config defaults" do
          expect(output).to include("Configuration")
          expect_config_to_be_printed(Appsignal::Config::DEFAULT_CONFIG)
        end

        it "transmits config in report" do
          expect(received_report["config"]).to match(
            "options" => hash_with_string_keys(config.config_hash).merge("env" => "foobar"),
            "sources" => {
              "default" => hash_with_string_keys(Appsignal::Config::DEFAULT_CONFIG),
              "system" => {},
              "initial" => hash_with_string_keys(config.initial_config),
              "file" => hash_with_string_keys(config.file_config),
              "env" => {}
            }
          )
        end
      end

      def expect_config_to_be_printed(config)
        nil_options = config.select { |_, v| v.nil? }
        nil_options.each_key do |key|
          expect(output).to include(%(#{key}: nil))
        end
        string_options = config.select { |_, v| v.is_a?(String) }
        string_options.each do |key, value|
          expect(output).to include(%(#{key}: "#{value}"))
        end
        other_options = config.select do |k, _|
          !string_options.key?(k) && !nil_options.key?(k)
        end
        other_options.each do |key, value|
          expect(output).to include(%(#{key}: #{value}))
        end
      end
    end

    describe "API key validation", :api_stub => false do
      context "with valid key" do
        before do
          stub_api_request(config, "auth").to_return(:status => 200)
          run
        end

        it "outputs valid" do
          expect(output).to include "Validation",
            "Validating Push API key: valid"
        end

        it "transmits validation in report" do
          expect(received_report).to include(
            "validation" => {
              "push_api_key" => "valid"
            }
          )
        end
      end

      context "with invalid key" do
        before do
          stub_api_request(config, "auth").to_return(:status => 401)
          run
        end

        it "outputs invalid" do
          expect(output).to include "Validation",
            "Validating Push API key: invalid"
        end

        it "transmits validation in report" do
          expect(received_report).to include(
            "validation" => {
              "push_api_key" => "invalid"
            }
          )
        end
      end

      context "with invalid key" do
        before do
          stub_api_request(config, "auth").to_return(:status => 500)
          run
        end

        it "outputs failure with status code" do
          expect(output).to include "Validation",
            "Validating Push API key: Failed with status 500\n" +
            %("Could not confirm authorization: 500")
        end

        it "transmits validation in report" do
          expect(received_report).to include(
            "validation" => {
              "push_api_key" => %(Failed with status 500\n\"Could not confirm authorization: 500")
            }
          )
        end
      end
    end

    describe "paths" do
      let(:config) { Appsignal::Config.new(root_path, "production") }
      let(:root_path) { tmp_dir }
      let(:system_tmp_dir) { Appsignal::Config.system_tmp_dir }
      before do
        FileUtils.mkdir_p(root_path)
        FileUtils.mkdir_p(system_tmp_dir)
      end
      after { FileUtils.rm_rf([root_path, system_tmp_dir]) }

      describe "report" do
        it "adds paths to the report" do
          run_within_dir root_path
          expect(received_report["paths"].keys).to match_array(
            %w[
              package_install_path root_path working_dir log_dir_path
              ext/mkmf.log appsignal.log
            ]
          )
        end

        describe "working_dir" do
          before { run_within_dir root_path }

          it "outputs current path" do
            expect(output).to include %(Current working directory\n    Path: "#{tmp_dir}")
          end

          it "transmits path data in report" do
            expect(received_report["paths"]["working_dir"]).to match(
              "path" => tmp_dir,
              "exists" => true,
              "type" => "directory",
              "mode" => kind_of(String),
              "writable" => boolean,
              "ownership" => {
                "uid" => kind_of(Integer),
                "user" => kind_of(String),
                "gid" => kind_of(Integer),
                "group" => kind_of(String)
              }
            )
          end
        end

        describe "root_path" do
          before { run_within_dir root_path }

          it "outputs root path" do
            expect(output).to include %(Root path\n    Path: "#{root_path}")
          end

          it "transmits path data in report" do
            expect(received_report["paths"]["root_path"]).to match(
              "path" => root_path,
              "exists" => true,
              "type" => "directory",
              "mode" => kind_of(String),
              "writable" => boolean,
              "ownership" => {
                "uid" => kind_of(Integer),
                "user" => kind_of(String),
                "gid" => kind_of(Integer),
                "group" => kind_of(String)
              }
            )
          end
        end

        describe "package_install_path" do
          before { run_within_dir root_path }

          it "outputs gem install path" do
            expect(output).to match %(AppSignal gem path\n    Path: "#{gem_path}")
          end

          it "transmits path data in report" do
            expect(received_report["paths"]["package_install_path"]).to match(
              "path" => gem_path,
              "exists" => true,
              "type" => "directory",
              "mode" => kind_of(String),
              "writable" => boolean,
              "ownership" => {
                "uid" => kind_of(Integer),
                "user" => kind_of(String),
                "gid" => kind_of(Integer),
                "group" => kind_of(String)
              }
            )
          end
        end

        describe "log_dir_path" do
          before { run_within_dir root_path }

          it "outputs log directory path" do
            expect(output).to match %(Log directory\n    Path: "#{system_tmp_dir}")
          end

          it "transmits path data in report" do
            expect(received_report["paths"]["log_dir_path"]).to match(
              "path" => system_tmp_dir,
              "exists" => true,
              "type" => "directory",
              "mode" => kind_of(String),
              "writable" => boolean,
              "ownership" => {
                "uid" => kind_of(Integer),
                "user" => kind_of(String),
                "gid" => kind_of(Integer),
                "group" => kind_of(String)
              }
            )
          end
        end
      end

      context "when a directory does not exist" do
        let(:root_path) { tmp_dir }
        let(:execution_path) { File.join(tmp_dir, "not_existing_dir") }
        let(:config) do
          silence(:allowed => ["Push api key not set after loading config"]) do
            Appsignal::Config.new(execution_path, "production")
          end
        end
        before do
          allow(Dir).to receive(:pwd).and_return(execution_path)
          run_within_dir tmp_dir
        end

        it "outputs not existing path" do
          expect(output).to include %(Root path\n    Path: "#{execution_path}"\n    Exists?: false)
        end

        it "transmits path data in report" do
          expect(received_report["paths"]["root_path"]).to eq(
            "path" => execution_path,
            "exists" => false
          )
        end
      end

      context "when not writable" do
        let(:root_path) { File.join(tmp_dir, "not_writable_path") }
        before do
          FileUtils.chmod(0o555, root_path)
          run_within_dir root_path
        end

        it "outputs not writable root path" do
          expect(output).to include %(Root path\n    Path: "#{root_path}"\n    Writable?: false)
        end

        it "transmits path data in report" do
          expect(received_report["paths"]["root_path"]).to eq(
            "path" => root_path,
            "exists" => true,
            "type" => "directory",
            "mode" => "40555",
            "writable" => false,
            "ownership" => {
              "uid" => Process.uid,
              "user" => process_user,
              "gid" => Process.gid,
              "group" => process_group
            }
          )
        end
      end

      context "when writable" do
        let(:root_path) { File.join(tmp_dir, "writable_path") }
        before do
          FileUtils.chmod(0o755, root_path)
          run_within_dir root_path
        end

        it "outputs writable root path" do
          expect(output).to include %(Root path\n    Path: "#{root_path}"\n    Writable?: true)
        end

        it "transmits path data in report" do
          expect(received_report["paths"]["root_path"]).to eq(
            "path" => root_path,
            "exists" => true,
            "type" => "directory",
            "mode" => "40755",
            "writable" => true,
            "ownership" => {
              "uid" => Process.uid,
              "user" => process_user,
              "gid" => Process.gid,
              "group" => process_group
            }
          )
        end
      end

      describe "ownership" do
        context "when a directory is owned by the current user" do
          let(:root_path) { File.join(tmp_dir, "owned_path") }
          before { run_within_dir root_path }

          it "outputs ownership" do
            expect(output).to include \
              %(Root path\n    Path: "#{root_path}"\n    Writable?: true\n    ) \
                "Ownership?: true (file: #{process_user}:#{Process.uid}, "\
                "process: #{process_user}:#{Process.uid})"
          end

          it "transmits path data in report" do
            mode = ENV["RUNNING_IN_CI"] ? "40775" : "40755"
            expect(received_report["paths"]["root_path"]).to eq(
              "path" => root_path,
              "exists" => true,
              "type" => "directory",
              "mode" => mode,
              "writable" => true,
              "ownership" => {
                "uid" => Process.uid,
                "user" => process_user,
                "gid" => Process.gid,
                "group" => process_group
              }
            )
          end
        end

        context "when a directory is not owned by the current user" do
          let(:root_path) { File.join(tmp_dir, "not_owned_path") }
          before do
            stat = File.stat(root_path)
            allow(stat).to receive(:uid).and_return(0)
            allow(File).to receive(:stat).and_return(stat)
            run_within_dir root_path
          end

          it "outputs no ownership" do
            expect(output).to include \
              %(Root path\n    Path: "#{root_path}"\n    Writable?: true\n    ) \
                "Ownership?: false (file: root:0, process: #{process_user}:#{Process.uid})"
          end
        end
      end
    end

    describe "files" do
      shared_examples "diagnose file" do |shared_example_options|
        let(:parent_directory) { File.join(tmp_dir, "diagnose_files") }
        let(:file_path) { File.join(parent_directory, filename) }
        before { FileUtils.mkdir_p File.dirname(file_path) }
        after { FileUtils.rm_rf parent_directory }

        context "when file exists" do
          let(:contents) do
            [].tap do |lines|
              (1..12).each do |i|
                lines << "log line #{i}"
              end
            end
          end
          before do
            File.open file_path, "a" do |f|
              contents.each do |line|
                f.puts line
              end
            end
            run
          end

          it "outputs file location and content" do
            expect(output).to include(
              %(Path: "#{file_path}"),
              "Contents (last 10 lines):"
            )
            expect(output).to include(*contents.last(10).join("\n"))
            expect(output).to_not include(*contents.first(2).join("\n"))
          end

          it "transmits file data in report" do
            expect(received_report["paths"][filename]).to match(
              "path" => file_path,
              "exists" => true,
              "type" => "file",
              "mode" => kind_of(String),
              "writable" => boolean,
              "ownership" => {
                "uid" => kind_of(Integer),
                "user" => kind_of(String),
                "gid" => kind_of(Integer),
                "group" => kind_of(String)
              },
              "content" => contents
            )
          end
        end

        context "when file does not exist" do
          before do
            if shared_example_options && shared_example_options[:stub_not_exists]
              allow(File).to receive(:exist?).and_call_original
              expect(File).to receive(:exist?).with(file_path).and_return(false)
            end
            run
          end

          it "outputs file does not exists" do
            expect(output).to include %(Path: "#{file_path}"\n    Exists?: false)
          end

          it "transmits file data in report" do
            expect(received_report["paths"][filename]).to eq(
              "path" => file_path,
              "exists" => false
            )
          end
        end
      end

      describe "mkmf.log" do
        it_behaves_like "diagnose file" do
          let(:filename) { File.join("ext", "mkmf.log") }
          before do
            expect_any_instance_of(Appsignal::CLI::Diagnose::Paths).to receive(:gem_path)
              .at_least(:once)
              .and_return(parent_directory)
          end
        end

        it "outputs header" do
          run
          expect(output).to include("Makefile install log")
        end
      end

      describe "appsignal.log" do
        it_behaves_like "diagnose file", :stub_not_exists => true do
          let(:filename) { "appsignal.log" }
          before do
            ENV["APPSIGNAL_LOG"] = "stdout"
            expect_any_instance_of(Appsignal::Config).to receive(:log_file_path).at_least(:once).and_return(file_path)
          end
        end

        it "outputs header" do
          run
          expect(output).to include("AppSignal log")
        end
      end
    end
  end

  def expect_valid_agent_diagnostics_report(output, working_directory_stat)
    expect(output).to include \
      "Agent diagnostics",
      "  Extension tests\n    Configuration: valid",
      "    Started: started",
      "    Process user id: #{Process.uid}",
      "    Process user group id: #{Process.gid}\n" \
        "    Configuration: valid",
      "    Logger: started",
      "    Working directory user id: #{working_directory_stat.uid}",
      "    Working directory user group id: #{working_directory_stat.gid}",
      "    Working directory permissions: #{working_directory_stat.mode}",
      "    Lock path: writable"
  end

  def hash_with_string_keys(hash)
    Hash[hash.map { |key, value| [key.to_s, value] }]
  end
end
