lib/react_on_rails/dev/server_manager.rb



# frozen_string_literal: true

require "English"
require "fileutils"
require "net/http"
require "open3"
require "optparse"
require "rainbow"
require "erb"
require "rbconfig"
require "socket"
require "time"
require "yaml"
require_relative "../packer_utils"
require_relative "database_checker"
require_relative "service_checker"

module ReactOnRails
  module Dev
    class ServerManager
      HELP_FLAGS = ["-h", "--help"].freeze
      TEST_WATCH_MODES = %w[auto full client-only].freeze
      OPEN_BROWSER_WAIT_TIMEOUT = 60
      OPEN_BROWSER_POLL_INTERVAL = 0.5
      # Relative to Dir.pwd; bin/dev is expected to run from the Rails app root.
      OPEN_BROWSER_ONCE_MARKER = File.join("tmp", "react_on_rails", "browser_opened_once").freeze

      class << self
        def start(mode = :development, procfile = nil, verbose: false, route: nil, rails_env: nil,
                  skip_database_check: false, open_browser: false, open_browser_once: false)
          case mode
          when :production_like
            run_production_like(_verbose: verbose, route: route, rails_env: rails_env,
                                skip_database_check: skip_database_check,
                                open_browser: open_browser,
                                open_browser_once: open_browser_once)
          when :static
            procfile ||= "Procfile.dev-static-assets"
            run_static_development(procfile, verbose: verbose, route: route,
                                             skip_database_check: skip_database_check,
                                             open_browser: open_browser,
                                             open_browser_once: open_browser_once)
          when :development, :hmr
            procfile ||= "Procfile.dev"
            run_development(procfile, verbose: verbose, route: route,
                                      skip_database_check: skip_database_check,
                                      open_browser: open_browser,
                                      open_browser_once: open_browser_once)
          else
            raise ArgumentError, "Unknown mode: #{mode}"
          end
        end

        def kill_processes
          puts "🔪 Killing all development processes..."
          puts ""

          killed_any = kill_running_processes || kill_port_processes([3000, 3001]) || cleanup_socket_files

          print_kill_summary(killed_any)
        end

        def development_processes
          {
            "rails" => "Rails server",
            "node.*react[-_]on[-_]rails" => "React on Rails Node processes",
            "overmind" => "Overmind process manager",
            "foreman" => "Foreman process manager",
            "ruby.*puma" => "Puma server",
            "webpack-dev-server" => "Webpack dev server",
            "bin/shakapacker-dev-server" => "Shakapacker dev server"
          }
        end

        def kill_running_processes
          killed_any = false

          development_processes.each do |pattern, description|
            pids = find_process_pids(pattern)
            next unless pids.any?

            puts "   ☠️  Killing #{description} (PIDs: #{pids.join(', ')})"
            terminate_processes(pids)
            killed_any = true
          end

          killed_any
        end

        def find_process_pids(pattern)
          stdout, _status = Open3.capture2("pgrep", "-f", pattern, err: File::NULL)
          stdout.split("\n").map(&:to_i).reject { |pid| pid == Process.pid }
        rescue Errno::ENOENT
          # pgrep command not found
          []
        end

        def terminate_processes(pids)
          pids.each do |pid|
            Process.kill("TERM", pid)
          rescue Errno::ESRCH, ArgumentError, RangeError
            # Process already stopped, or invalid signal/PID - silently skip
            nil
          rescue Errno::EPERM
            # Permission denied - warn the user
            puts "   ⚠️  Process #{pid} - permission denied (process owned by another user)"
            nil
          end
        end

        def kill_port_processes(ports)
          killed_any = false

          ports.each do |port|
            pids = find_port_pids(port)
            next unless pids.any?

            puts "   ☠️  Killing process on port #{port} (PIDs: #{pids.join(', ')})"
            terminate_processes(pids)
            killed_any = true
          end

          killed_any
        end

        def find_port_pids(port)
          stdout, _status = Open3.capture2("lsof", "-ti", ":#{port}", err: File::NULL)
          stdout.split("\n").map(&:to_i).reject { |pid| pid == Process.pid }
        rescue StandardError
          # lsof command not found or other error (permission denied, etc.)
          []
        end

        def cleanup_socket_files
          files = [".overmind.sock", "tmp/sockets/overmind.sock", "tmp/pids/server.pid"]
          killed_any = false

          files.each do |file|
            next unless File.exist?(file)

            puts "   🧹 Removing #{file}"
            File.delete(file)
            killed_any = true
          rescue StandardError
            nil
          end

          killed_any
        end

        def print_kill_summary(killed_any)
          if killed_any
            puts ""
            puts "✅ All processes terminated and sockets cleaned"
            puts "💡 You can now run 'bin/dev' for a clean start"
          else
            puts "   ℹ️  No development processes found running"
          end
        end

        def show_help
          puts help_usage
          puts ""
          puts help_commands
          puts ""
          puts help_options
          puts ""
          puts help_customization
          puts ""
          puts help_mode_details
          puts ""
          puts help_troubleshooting
        end

        # Flags that take a value as the next argument (not using = syntax)
        FLAGS_WITH_VALUES = %w[--route --rails-env --test-watch-mode].freeze

        # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
        def run_from_command_line(args = ARGV)
          # Get the command early to check for help/kill before running hooks
          # We need to do this before OptionParser processes flags like -h/--help
          # Skip arguments that are values for flags (e.g., "hello_world" after "--route")
          command = extract_command_from_args(args)

          # Check if help flags are present in args (before OptionParser processes them)
          help_requested = args.any? { |arg| HELP_FLAGS.include?(arg) }

          options = parse_cli_options(args)

          # Run precompile hook once before starting any mode (except kill/help)
          # Then set environment variable to prevent duplicate execution in spawned processes.
          # Note: We always set SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true (even when no hook is configured)
          # to provide a consistent signal that bin/dev is managing the precompile lifecycle.
          # This allows custom scripts to detect bin/dev's presence and adjust behavior accordingly.
          unless %w[kill help].include?(command) || help_requested
            run_precompile_hook_if_present
            ENV["SHAKAPACKER_SKIP_PRECOMPILE_HOOK"] = "true"
          end

          # Main execution
          case command
          when "production-assets", "prod"
            start(:production_like, nil, verbose: options[:verbose], route: options[:route],
                                         rails_env: options[:rails_env],
                                         skip_database_check: options[:skip_database_check],
                                         open_browser: options[:open_browser],
                                         open_browser_once: options[:open_browser_once])
          when "static"
            start(:static, "Procfile.dev-static-assets", verbose: options[:verbose], route: options[:route],
                                                         skip_database_check: options[:skip_database_check],
                                                         open_browser: options[:open_browser],
                                                         open_browser_once: options[:open_browser_once])
          when "kill"
            kill_processes
          when "help"
            show_help
          when "test-watch"
            run_test_watch(test_watch_mode: options[:test_watch_mode])
          when "hmr", nil
            start(:development, "Procfile.dev", verbose: options[:verbose], route: options[:route],
                                                skip_database_check: options[:skip_database_check],
                                                open_browser: options[:open_browser],
                                                open_browser_once: options[:open_browser_once])
          else
            puts "Unknown argument: #{command}"
            puts "Run 'dev help' for usage information"
            exit 1
          end
        end
        # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity

        private

        # Extract the command from args, skipping flag values
        # For example, in ["--route", "hello_world"], "hello_world" is a flag value, not a command
        # But in ["static", "--route", "hello_world"], "static" is the command
        def extract_command_from_args(args)
          skip_next = false
          args.each do |arg|
            if skip_next
              skip_next = false
              next
            end

            # Check if this flag takes a value as the next argument
            if FLAGS_WITH_VALUES.include?(arg)
              skip_next = true
              next
            end

            # Skip any flag (starts with - or --)
            next if arg.start_with?("-")

            # Found a non-flag, non-value argument - this is the command
            return arg
          end
          nil
        end

        def run_precompile_hook_if_present
          require "open3"
          require "shellwords"

          hook_value = PackerUtils.shakapacker_precompile_hook_value
          return unless hook_value

          # Warn if Shakapacker version doesn't support SHAKAPACKER_SKIP_PRECOMPILE_HOOK
          warn_if_shakapacker_version_too_old

          puts Rainbow("🔧 Running Shakapacker precompile hook...").cyan
          puts Rainbow("   Command: #{hook_value}").cyan
          puts ""

          # Capture stdout and stderr for better error reporting
          # Use Shellwords.split for safer command execution (prevents shell metacharacter interpretation)
          command_args = Shellwords.split(hook_value.to_s)
          stdout, stderr, status = Open3.capture3(*command_args)

          if status.success?
            puts Rainbow("✅ Precompile hook completed successfully").green
            puts ""
          else
            handle_precompile_hook_failure(hook_value, stdout, stderr)
          end
        end

        def run_test_watch(test_watch_mode: "auto")
          resolved_mode = resolve_test_watch_mode(test_watch_mode)
          return unless resolved_mode

          env = { "RAILS_ENV" => "test" }
          if resolved_mode == "client-only"
            env["CLIENT_BUNDLE_ONLY"] = "true"
            puts Rainbow("🧪 Starting test watch (client-only mode)...").cyan
            puts Rainbow("   Reusing server bundle from existing watcher if available.").cyan
          else
            puts Rainbow("🧪 Starting test watch (full mode)...").cyan
            puts Rainbow("   Building both client and server test bundles.").cyan
          end
          puts Rainbow("   Command: #{env.map { |k, v| "#{k}=#{v}" }.join(' ')} bin/shakapacker --watch").cyan
          puts ""

          exec(env, "bin/shakapacker", "--watch")
        end

        def resolve_test_watch_mode(mode)
          normalized_mode = mode.to_s.strip
          normalized_mode = "auto" if normalized_mode.empty?

          unless TEST_WATCH_MODES.include?(normalized_mode)
            puts "❌ Invalid --test-watch-mode '#{mode}'. Use one of: #{TEST_WATCH_MODES.join(', ')}"
            exit 1
          end

          return normalized_mode unless normalized_mode == "auto"

          shakapacker_watch_process_running? ? "client-only" : "full"
        end

        def shakapacker_watch_process_running?
          # Detect existing shakapacker watcher processes (from either bin/dev or bin/dev static).
          # If one is already running, client-only test watch avoids duplicate server-bundle rebuilds.
          # Also detect legacy =yes convention during transition
          server_only_watchers = find_process_pids("SERVER_BUNDLE_ONLY=true bin/shakapacker --watch")
          server_only_watchers |= find_process_pids("SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch")
          if server_only_watchers.any?
            return true if shared_private_output_paths?

            puts Rainbow(
              "   Existing server-bundle-only watcher found, " \
              "but test/development private outputs differ; using full mode."
            ).yellow
            return false
          end

          full_watchers = find_process_pids("bin/shakapacker --watch")
          if full_watchers.any? && shakapacker_dev_server_running?
            return true if shared_private_output_paths?

            puts Rainbow(
              "   Existing dev-server/watcher pair found, " \
              "but test/development private outputs differ; using full mode."
            ).yellow
            return false
          end

          return false if full_watchers.empty?
          return true if shared_private_output_paths?

          puts Rainbow("   Existing shakapacker watcher found, but bundle sharing is unclear; using full mode.").yellow

          false
        end

        def shakapacker_dev_server_running?
          find_process_pids("bin/shakapacker-dev-server").any?
        end

        def shared_private_output_paths?
          shakapacker_config = parsed_shakapacker_config
          return false unless shakapacker_config.is_a?(Hash)

          default_config = shakapacker_config["default"] || {}
          development_config = default_config.merge(shakapacker_config["development"] || {})
          test_config = default_config.merge(shakapacker_config["test"] || {})
          development_private = development_config["private_output_path"]
          test_private = test_config["private_output_path"]

          return false unless development_private && test_private

          development_private == test_private
        end

        def parsed_shakapacker_config
          config_path = ENV["SHAKAPACKER_CONFIG"] || "config/shakapacker.yml"
          return nil unless File.exist?(config_path)

          YAML.safe_load(ERB.new(File.read(config_path)).result, aliases: true, permitted_classes: [Symbol])
        rescue StandardError
          nil
        end

        # rubocop:disable Metrics/AbcSize
        def handle_precompile_hook_failure(hook_value, stdout, stderr)
          puts ""
          puts Rainbow("❌ Precompile hook failed!").red.bold
          puts Rainbow("   Command: #{hook_value}").red
          puts ""

          if stdout && !stdout.strip.empty?
            puts Rainbow("   Output:").yellow
            stdout.strip.split("\n").each { |line| puts Rainbow("   #{line}").yellow }
            puts ""
          end

          if stderr && !stderr.strip.empty?
            puts Rainbow("   Error:").red
            stderr.strip.split("\n").each { |line| puts Rainbow("   #{line}").red }
            puts ""
          end

          puts Rainbow("💡 Fix the hook command in config/shakapacker.yml or remove it to continue").yellow
          exit 1
        end
        # rubocop:enable Metrics/AbcSize

        # rubocop:disable Metrics/AbcSize
        def warn_if_shakapacker_version_too_old
          # Only warn for Shakapacker versions in the range 9.0.0 to 9.3.x
          # Versions below 9.0.0 don't use the precompile_hook feature
          # Versions 9.4.0+ support SHAKAPACKER_SKIP_PRECOMPILE_HOOK environment variable natively
          has_precompile_hook_support = PackerUtils.shakapacker_version_requirement_met?("9.0.0")
          has_skip_env_var_support = PackerUtils.shakapacker_version_requirement_met?("9.4.0")

          return unless has_precompile_hook_support
          return if has_skip_env_var_support

          hook_value = PackerUtils.shakapacker_precompile_hook_value
          return unless hook_value

          # Case 1: Script-based hook WITH self-guard -> fully protected, no warning needed
          return if PackerUtils.hook_script_has_self_guard?(hook_value)

          # Case 2: Script-based hook WITHOUT self-guard -> actionable warning
          script_path = PackerUtils.resolve_hook_script_path(hook_value)
          if script_path
            puts ""
            puts Rainbow("⚠️  Warning: #{script_path} is missing the self-guard line").yellow.bold
            puts ""
            puts Rainbow("   Without it, the precompile hook may run multiple times in HMR mode").yellow
            puts Rainbow("   (once by bin/dev, and again by each webpack process).").yellow
            puts ""
            puts Rainbow("   Add this line near the top of your hook script:").cyan
            puts Rainbow('   exit 0 if ENV["SHAKAPACKER_SKIP_PRECOMPILE_HOOK"] == "true"').cyan.bold
            puts ""
            return
          end

          # Case 3: Direct command hook -> suggest upgrade or switch to script-based hook
          puts ""
          puts Rainbow("⚠️  Warning: Shakapacker #{PackerUtils.shakapacker_version} detected").yellow.bold
          puts ""
          puts Rainbow("   The SHAKAPACKER_SKIP_PRECOMPILE_HOOK environment variable is not").yellow
          puts Rainbow("   supported in Shakapacker versions below 9.4.0. This may cause the").yellow
          puts Rainbow("   precompile_hook to run multiple times (once by bin/dev, and again").yellow
          puts Rainbow("   by each webpack process).").yellow
          puts ""
          puts Rainbow("   Recommendations:").cyan
          puts Rainbow("   1. Upgrade to Shakapacker 9.4.0 or later:").cyan
          puts Rainbow("      bundle update shakapacker").cyan.bold
          puts Rainbow("   2. Or switch to a script-based hook with a self-guard.").cyan
          puts Rainbow("      See: https://reactonrails.com/docs/building-features/process-managers").cyan
          puts ""
        end
        # rubocop:enable Metrics/AbcSize

        def help_usage
          Rainbow("📋 Usage: bin/dev [command] [options]").bold
        end

        # rubocop:disable Metrics/AbcSize
        def help_commands
          <<~COMMANDS
            #{Rainbow('🚀 COMMANDS:').cyan.bold}
              #{Rainbow('(none) / hmr').green.bold}        #{Rainbow('Start development server with HMR (default)').white}
                                  #{Rainbow('→ Uses:').yellow} Procfile.dev

              #{Rainbow('static').green.bold}              #{Rainbow('Start development server with static assets (no HMR, no FOUC)').white}
                                  #{Rainbow('→ Uses:').yellow} Procfile.dev-static-assets

              #{Rainbow('production-assets').green.bold}   #{Rainbow('Start with production-optimized assets (no HMR)').white}
              #{Rainbow('prod').green.bold}                #{Rainbow('Alias for production-assets').white}
                                  #{Rainbow('→ Uses:').yellow} Procfile.dev-prod-assets

              #{Rainbow('test-watch').green.bold}          #{Rainbow('Watch and rebuild test assets with smart defaults').white}
                                  #{Rainbow('→ Uses:').yellow} bin/shakapacker --watch (RAILS_ENV=test)

              #{Rainbow('kill').red.bold}                #{Rainbow('Kill all development processes for a clean start').white}
              #{Rainbow('help').blue.bold}                #{Rainbow('Show this help message').white}
          COMMANDS
        end
        # rubocop:enable Metrics/AbcSize

        # rubocop:disable Metrics/AbcSize
        def help_options
          <<~OPTIONS
            #{Rainbow('⚙️  OPTIONS:').cyan.bold}
              #{Rainbow('--route ROUTE').green.bold}        #{Rainbow('Specify route to display in URLs (default: root)').white}
              #{Rainbow('--rails-env ENV').green.bold}      #{Rainbow('Override RAILS_ENV for assets:precompile step only (prod mode only)').white}
              #{Rainbow('--verbose, -v').green.bold}        #{Rainbow('Enable verbose output for pack generation').white}
              #{Rainbow('--skip-database-check').green.bold} #{Rainbow('Skip database connectivity check (saves ~1-2s startup time)').white}
              #{Rainbow('--open-browser').green.bold}       #{Rainbow('Open the app URL in your browser when the server is ready').white}
              #{Rainbow('--open-browser-once').green.bold}  #{Rainbow('Open the app once, then remember that it was already opened').white}
              #{Rainbow('--no-open-browser').green.bold}    #{Rainbow('Disable automatic browser opening for this run').white}
              #{Rainbow('--test-watch-mode MODE').green.bold} #{Rainbow('For test-watch: auto, full, or client-only').white}

            #{Rainbow('📝 EXAMPLES:').cyan.bold}
              #{Rainbow('bin/dev prod').green.bold}                    #{Rainbow('# NODE_ENV=production, RAILS_ENV=development').white}
              #{Rainbow('bin/dev prod --rails-env=production').green.bold}  #{Rainbow('# NODE_ENV=production, RAILS_ENV=production').white}
              #{Rainbow('bin/dev prod --route=dashboard').green.bold}       #{Rainbow('# Custom route in URLs').white}
              #{Rainbow('bin/dev --skip-database-check').green.bold}        #{Rainbow('# Skip DB check for faster startup').white}
              #{Rainbow('bin/dev --open-browser').green.bold}               #{Rainbow('# Open the app after the server comes up').white}
              #{Rainbow('bin/dev --no-open-browser').green.bold}            #{Rainbow('# Override generated auto-open behavior').white}
              #{Rainbow('bin/dev test-watch').green.bold}                    #{Rainbow('# Auto-select full/client-only test watch').white}
              #{Rainbow('bin/dev test-watch --test-watch-mode=full').green.bold} #{Rainbow('# Always build server+client test bundles').white}
          OPTIONS
        end
        # rubocop:enable Metrics/AbcSize

        # rubocop:disable Metrics/AbcSize
        def help_customization
          <<~CUSTOMIZATION
            #{Rainbow('🔧 CUSTOMIZATION:').cyan.bold}
            Each mode uses a specific Procfile that you can customize for your application:

            #{Rainbow('•').yellow} #{Rainbow('Procfile.dev').green.bold}                 - HMR development with webpack-dev-server
            #{Rainbow('•').yellow} #{Rainbow('Procfile.dev-static-assets').green.bold}   - Static development with webpack --watch
            #{Rainbow('•').yellow} #{Rainbow('Procfile.dev-prod-assets').green.bold}     - Production-optimized assets (port 3001)

            #{Rainbow('Edit these files to customize the development environment for your needs.').white}

            #{Rainbow('🗄️  DATABASE CHECK:').cyan.bold}
            #{Rainbow('bin/dev checks database connectivity before starting (adds ~1-2s to startup).').white}
            #{Rainbow('Disable this check if you don\'t use a database or want faster startup:').white}

            #{Rainbow('•').yellow} #{Rainbow('CLI flag:').white}     #{Rainbow('bin/dev --skip-database-check').green.bold}
            #{Rainbow('•').yellow} #{Rainbow('Environment:').white}  #{Rainbow('SKIP_DATABASE_CHECK=true bin/dev').green.bold}
            #{Rainbow('•').yellow} #{Rainbow('Config:').white}       #{Rainbow('config.check_database_on_dev_start = false').green.bold} #{Rainbow('(in react_on_rails.rb)').white}

            #{Rainbow('🔍 SERVICE DEPENDENCIES:').cyan.bold}
            #{Rainbow('Configure required external services in').white} #{Rainbow('.dev-services.yml').green.bold}#{Rainbow(':').white}

            #{Rainbow('•').yellow} #{Rainbow('bin/dev').white} #{Rainbow('checks services before starting (optional)').white}
            #{Rainbow('•').yellow} #{Rainbow('Copy from').white} #{Rainbow('.dev-services.yml.example').green.bold} #{Rainbow('to get started').white}
            #{Rainbow('•').yellow} #{Rainbow('Supports Redis, PostgreSQL, Elasticsearch, and custom services').white}
            #{Rainbow('•').yellow} #{Rainbow('Shows helpful errors with start commands if services are missing').white}

            #{Rainbow('🧪 TEST ASSET WORKFLOWS:').cyan.bold}
            #{Rainbow('Recommended default (separate outputs):').white}
            #{Rainbow('•').yellow} #{Rainbow('Keep test public_output_path different from development (for example, packs-test vs packs)').white}
            #{Rainbow('•').yellow} #{Rainbow('Use').white} #{Rainbow('bin/dev').green.bold} #{Rainbow('for HMR').white}
            #{Rainbow('•').yellow} #{Rainbow('Use').white} #{Rainbow('bin/dev test-watch').green.bold} #{Rainbow('to watch test assets').white}
            #{Rainbow('•').yellow} #{Rainbow('Override mode when needed:').white} #{Rainbow('--test-watch-mode=full').green.bold} #{Rainbow('or').white} #{Rainbow('--test-watch-mode=client-only').green.bold}

            #{Rainbow('Advanced static-only workflow (shared output):').white}
            #{Rainbow('•').yellow} #{Rainbow('Only use shared test/dev output with').white} #{Rainbow('bin/dev static').green.bold}
            #{Rainbow('•').yellow} #{Rainbow('Do not combine shared output path with').white} #{Rainbow('bin/dev').red.bold} #{Rainbow('(HMR)').white}

            #{Rainbow('Example .dev-services.yml:').white}
            #{Rainbow('  services:').cyan}
            #{Rainbow('    redis:').cyan}
            #{Rainbow('      check_command: "redis-cli ping"').cyan}
            #{Rainbow('      expected_output: "PONG"').cyan}
            #{Rainbow('      start_command: "redis-server"').cyan}
          CUSTOMIZATION
        end
        # rubocop:enable Metrics/AbcSize

        # rubocop:disable Metrics/AbcSize
        def help_mode_details
          <<~MODES
            #{Rainbow('🔥 HMR Development mode (default)').cyan.bold} - #{Rainbow('Procfile.dev').green}:
            #{Rainbow('•').yellow} #{Rainbow('Hot Module Replacement (HMR) enabled').white}
            #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or bin/dev)').white}
            #{Rainbow('•').yellow} #{Rainbow('Webpack dev server for fast recompilation').white}
            #{Rainbow('•').yellow} #{Rainbow('Source maps for debugging').white}
            #{Rainbow('•').yellow} #{Rainbow('May have Flash of Unstyled Content (FOUC)').white}
            #{Rainbow('•').yellow} #{Rainbow('Fast recompilation').white}
            #{Rainbow('•').yellow} #{Rainbow('Access at:').white} #{Rainbow('http://localhost:3000/<route>').cyan.underline}

            #{Rainbow('📦 Static development mode').cyan.bold} - #{Rainbow('Procfile.dev-static-assets').green}:
            #{Rainbow('•').yellow} #{Rainbow('No HMR (static assets with auto-recompilation)').white}
            #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or bin/dev)').white}
            #{Rainbow('•').yellow} #{Rainbow('Webpack watch mode for auto-recompilation').white}
            #{Rainbow('•').yellow} #{Rainbow('CSS extracted to separate files (no FOUC)').white}
            #{Rainbow('•').yellow} #{Rainbow('Development environment (faster builds than production)').white}
            #{Rainbow('•').yellow} #{Rainbow('Source maps for debugging').white}
            #{Rainbow('•').yellow} #{Rainbow('Optional advanced testing: share output path with tests only in this mode').white}
            #{Rainbow('•').yellow} #{Rainbow('Access at:').white} #{Rainbow('http://localhost:3000/<route>').cyan.underline}

            #{Rainbow('🏭 Production-assets mode').cyan.bold} - #{Rainbow('Procfile.dev-prod-assets').green}:
            #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or assets:precompile)').white}
            #{Rainbow('•').yellow} #{Rainbow('Asset precompilation with NODE_ENV=production (webpack optimizations)').white}
            #{Rainbow('•').yellow} #{Rainbow('RAILS_ENV=development by default for assets:precompile (avoids credentials)').white}
            #{Rainbow('•').yellow} #{Rainbow('Use --rails-env=production for assets:precompile only (not server processes)').white}
            #{Rainbow('•').yellow} #{Rainbow('Server processes controlled by Procfile.dev-prod-assets environment').white}
            #{Rainbow('•').yellow} #{Rainbow('Optimized, minified bundles with CSS extraction').white}
            #{Rainbow('•').yellow} #{Rainbow('No HMR (static assets)').white}
            #{Rainbow('•').yellow} #{Rainbow('Access at:').white} #{Rainbow('http://localhost:3001/<route>').cyan.underline}
          MODES
        end
        # rubocop:enable Metrics/AbcSize

        # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
        def run_production_like(_verbose: false, route: nil, rails_env: nil, skip_database_check: false,
                                open_browser: false, open_browser_once: false)
          procfile = "Procfile.dev-prod-assets"

          # Set PORT before foreman starts — foreman injects its own PORT=5000
          # into child processes when ENV["PORT"] is unset, overriding the
          # ${PORT:-3001} fallback in the Procfile. Scan from 3001 (not 3000)
          # so prod-assets doesn't collide with the normal dev server.
          ENV["PORT"] ||= PortSelector.find_available_port(procfile_port(procfile)).to_s

          features = [
            "Precompiling assets with production optimizations",
            "Running Rails server on port #{procfile_port(procfile)}",
            "No HMR (Hot Module Replacement)",
            "CSS extracted to separate files (no FOUC)"
          ]

          # NOTE: Pack generation happens automatically during assets:precompile
          # either via precompile hook or via the configuration.rb adjust_precompile_task

          print_procfile_info(procfile, route: route)

          # Check database setup before starting
          exit 1 unless DatabaseChecker.check_database(skip: skip_database_check)

          # Check required services before starting
          exit 1 unless ServiceChecker.check_services

          print_server_info(
            "🏭 Starting production-like development server...",
            features,
            procfile_port(procfile),
            route: route
          )

          # Precompile assets with production webpack optimizations (includes pack generation automatically)
          env = { "NODE_ENV" => "production" }

          # Validate and sanitize rails_env to prevent shell injection
          if rails_env
            unless rails_env.match?(/\A[a-z0-9_]+\z/i)
              puts "❌ Invalid rails_env: '#{rails_env}'. Must contain only letters, numbers, and underscores."
              exit 1
            end
            env["RAILS_ENV"] = rails_env
          end

          argv = ["bundle", "exec", "rails", "assets:precompile"]

          puts "🔨 Precompiling assets with production webpack optimizations..."
          puts ""

          puts Rainbow("ℹ️  Asset Precompilation Environment:").blue
          puts "   • NODE_ENV=production → Webpack optimizations (minification, compression)"
          if rails_env
            puts "   • RAILS_ENV=#{rails_env} → Custom Rails environment for assets:precompile only"
            puts "   • Note: RAILS_ENV=production requires credentials, database setup, etc."
            puts "   • Server processes will use environment from Procfile.dev-prod-assets"
          else
            puts "   • RAILS_ENV=development → Simpler Rails setup (no credentials needed)"
            puts "   • Use --rails-env=production for assets:precompile step only"
            puts "   • Server processes will use environment from Procfile.dev-prod-assets"
            puts "   • Gets production webpack bundles without production Rails complexity"
          end
          puts ""

          env_display = env.map { |k, v| "#{k}=#{v}" }.join(" ")
          puts "#{Rainbow('💻 Running:').blue} #{env_display} #{argv.join(' ')}"
          puts ""

          # Capture both stdout and stderr
          require "open3"
          stdout, stderr, status = Open3.capture3(env, *argv)

          if status.success?
            puts "✅ Assets precompiled successfully"
            ensure_default_port(procfile)
            schedule_browser_open_if_requested(procfile,
                                               route: route,
                                               open_browser: open_browser,
                                               open_browser_once: open_browser_once)
            ProcessManager.ensure_procfile(procfile)
            ProcessManager.run_with_process_manager(procfile)
          else
            puts "❌ Asset precompilation failed"
            puts ""

            # Combine and display all output
            all_output = []
            all_output << stdout unless stdout.empty?
            all_output << stderr unless stderr.empty?

            unless all_output.empty?
              puts Rainbow("📋 Full Command Output:").red.bold
              puts Rainbow("─" * 60).red
              all_output.each { |output| puts output }
              puts Rainbow("─" * 60).red
              puts ""
            end

            puts Rainbow("🛠️  To debug this issue:").yellow.bold
            command_display = "#{env_display} #{argv.join(' ')}"
            puts "#{Rainbow('1.').cyan} #{Rainbow('Run the command separately to see detailed output:').white}"
            puts "   #{Rainbow(command_display).cyan}"
            puts ""
            puts "#{Rainbow('2.').cyan} #{Rainbow('Add --trace for full stack trace:').white}"
            puts "   #{Rainbow("#{command_display} --trace").cyan}"
            puts ""
            puts "#{Rainbow('3.').cyan} #{Rainbow('Or try with development webpack (faster, less optimized):').white}"
            puts "   #{Rainbow('NODE_ENV=development bundle exec rails assets:precompile').cyan}"
            puts ""

            puts Rainbow("💡 Common fixes:").yellow.bold

            # Provide specific guidance based on error content
            error_content = "#{stderr} #{stdout}".downcase

            if error_content.include?("secret_key_base")
              puts "#{Rainbow('•').yellow} #{Rainbow('Missing secret_key_base:').white.bold} " \
                   "Run #{Rainbow('bin/rails credentials:edit').cyan}"
            end

            if error_content.include?("database") || error_content.include?("relation") ||
               error_content.include?("table")
              puts "#{Rainbow('•').yellow} #{Rainbow('Database issues:').white.bold} " \
                   "Run #{Rainbow('bin/rails db:create db:migrate').cyan}"
            end

            if error_content.include?("gem") || error_content.include?("bundle") || error_content.include?("load error")
              puts "#{Rainbow('•').yellow} #{Rainbow('Missing dependencies:').white.bold} " \
                   "Run #{Rainbow('bundle install && npm install').cyan}"
            end

            if error_content.include?("webpack") || error_content.include?("module") ||
               error_content.include?("compilation")
              puts "#{Rainbow('•').yellow} #{Rainbow('Webpack compilation:').white.bold} " \
                   "Check JavaScript/webpack errors above"
            end

            # Always show these general options
            puts "#{Rainbow('•').yellow} #{Rainbow('Environment config:').white} " \
                 "Check #{Rainbow('config/environments/production.rb').cyan}"

            puts ""
            puts Rainbow("ℹ️  Alternative for development:").blue
            puts "   #{Rainbow('bin/dev static').green}  # Static assets without production optimizations"
            puts ""
            exit 1
          end
        end
        # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity

        def run_static_development(procfile, verbose: false, route: nil, skip_database_check: false,
                                   open_browser: false, open_browser_once: false)
          # Check database setup before starting
          exit 1 unless DatabaseChecker.check_database(skip: skip_database_check)

          # Check required services before starting
          exit 1 unless ServiceChecker.check_services

          # Configure ports before printing so the banner shows the correct URL
          configure_ports
          print_procfile_info(procfile, route: route)

          features = [
            "Using shakapacker --watch (no HMR)",
            "CSS extracted to separate files (no FOUC)",
            "Development environment (source maps, faster builds)",
            "Auto-recompiles on file changes"
          ]

          # Add pack generation info if not using precompile hook
          unless ReactOnRails::PackerUtils.shakapacker_precompile_hook_configured?
            features.unshift("Generating React on Rails packs")
          end

          print_server_info(
            "⚡ Starting development server with static assets...",
            features,
            procfile_port(procfile),
            route: route
          )

          PackGenerator.generate(verbose: verbose)
          ensure_default_port(procfile)
          schedule_browser_open_if_requested(procfile,
                                             route: route,
                                             open_browser: open_browser,
                                             open_browser_once: open_browser_once)
          ProcessManager.ensure_procfile(procfile)
          ProcessManager.run_with_process_manager(procfile)
        end

        def run_development(procfile, verbose: false, route: nil, skip_database_check: false,
                            open_browser: false, open_browser_once: false)
          # Check database setup before starting
          exit 1 unless DatabaseChecker.check_database(skip: skip_database_check)

          # Check required services before starting
          exit 1 unless ServiceChecker.check_services

          # Configure ports before printing so the banner shows the correct URL
          configure_ports
          print_procfile_info(procfile, route: route)

          PackGenerator.generate(verbose: verbose)
          ensure_default_port(procfile)
          schedule_browser_open_if_requested(procfile,
                                             route: route,
                                             open_browser: open_browser,
                                             open_browser_once: open_browser_once)
          ProcessManager.ensure_procfile(procfile)
          ProcessManager.run_with_process_manager(procfile)
        end

        def print_server_info(title, features, port = 3000, route: nil)
          puts title
          features.each { |feature| puts "   - #{feature}" }
          puts ""
          puts ""
          url = build_local_url(port, route)
          puts "💡 Access at: #{Rainbow(url).cyan.underline}"
          puts ""
        end

        def print_procfile_info(procfile, route: nil)
          port = procfile_port(procfile)
          box_width = 60
          url = build_local_url(port, route)

          puts ""
          puts box_border(box_width)
          puts box_empty_line(box_width)
          puts format_box_line("📋 Using Procfile: #{procfile}", box_width)
          puts format_box_line("🔧 Customize this file for your app's needs", box_width)
          puts box_empty_line(box_width)
          puts format_box_line("💡 Access at: #{Rainbow(url).cyan.underline}",
                               box_width)
          puts box_empty_line(box_width)
          puts box_bottom(box_width)
          puts ""
        end

        def configure_ports
          selected = PortSelector.select_ports
          ENV["PORT"] ||= selected[:rails].to_s
          ENV["SHAKAPACKER_DEV_SERVER_PORT"] ||= selected[:webpack].to_s
        rescue PortSelector::NoPortAvailable => e
          warn e.message
          exit 1
        end

        def procfile_port(procfile)
          if procfile == "Procfile.dev-prod-assets"
            ENV.fetch("PORT", 3001).to_i
          else
            ENV.fetch("PORT", 3000).to_i
          end
        end

        def ensure_default_port(procfile)
          return if ENV["PORT"].to_s.strip != ""

          ENV["PORT"] = procfile_port(procfile).to_s
        end

        def schedule_browser_open_if_requested(procfile, route:, open_browser:, open_browser_once:)
          return unless open_browser || open_browser_once

          # --open-browser and --open-browser-once share scheduling, but only the latter writes
          # the marker so explicit --open-browser continues to open on each invocation.
          schedule_browser_open(procfile_port(procfile), route: route, once: open_browser_once)
        end

        def build_local_url(port, route)
          path = normalize_route_path(route)
          path = "" if path == "/"
          "http://localhost:#{port}#{path}"
        end

        def build_request_path(route)
          normalize_route_path(route)
        end

        def normalize_route_path(route)
          stripped = route.to_s.strip
          return "/" if stripped.empty? || stripped == "/"

          stripped = stripped.sub(%r{\A/+}, "")
          "/#{stripped}"
        end

        def schedule_browser_open(port, route:, once:)
          return unless browser_auto_open_allowed?

          url = build_local_url(port, route)
          request_path = build_request_path(route)
          Thread.new do
            Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
            next unless wait_for_app_route(port, request_path)

            marker_state = prepare_browser_open_once_marker(once)
            next if marker_state == :already_opened

            if open_browser(url)
              nil
            else
              clear_browser_open_once_marker_if_claimed(marker_state)
              warn("[react_on_rails] Could not open browser automatically. Visit #{url} manually.")
            end
          rescue StandardError => e
            warn("[react_on_rails] Browser auto-open failed: #{e.message}")
          end
        end

        def browser_auto_open_allowed?
          !ENV.key?("CI") && $stdin.tty? && $stdout.tty?
        end

        def wait_for_app_route(port, request_path)
          deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + OPEN_BROWSER_WAIT_TIMEOUT

          loop do
            return true if app_route_ready?(port, request_path)
            return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

            sleep OPEN_BROWSER_POLL_INTERVAL
          end
        end

        LOCALHOST_ADDRESSES = %w[127.0.0.1 ::1].freeze
        private_constant :LOCALHOST_ADDRESSES

        def app_route_ready?(port, request_path)
          response = http_get_localhost(port, request_path)
          response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
        end

        def http_get_localhost(port, request_path)
          LOCALHOST_ADDRESSES.each do |host|
            response = Net::HTTP.start(host, port, open_timeout: 1, read_timeout: 1) do |http|
              http.get(request_path)
            end
            return response if response
          rescue StandardError
            next
          end
          nil
        end

        def open_browser(url)
          command = browser_command
          return false unless command

          system(*command, url, out: File::NULL, err: File::NULL)
        rescue StandardError
          false
        end

        def browser_command
          host_os = RbConfig::CONFIG["host_os"]
          return ["open"] if host_os.include?("darwin")

          if %w[linux bsd].any? { |platform| host_os.include?(platform) } && command_available?("xdg-open")
            return ["xdg-open"]
          end

          # "start" requires a window title before the URL; the empty string is the
          # conventional placeholder so Windows opens the browser instead of treating
          # the URL as the title.
          return ["cmd", "/c", "start", ""] if %w[mswin mingw cygwin].any? { |platform| host_os.include?(platform) }

          nil
        end

        def command_available?(command)
          ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
            executable = File.join(directory, command)
            File.file?(executable) && File.executable?(executable)
          end
        end

        def prepare_browser_open_once_marker(once)
          return :not_requested unless once

          FileUtils.mkdir_p(File.dirname(OPEN_BROWSER_ONCE_MARKER))
          File.open(OPEN_BROWSER_ONCE_MARKER, File::WRONLY | File::CREAT | File::EXCL) do |marker|
            marker.write("#{Time.now.utc.iso8601}\n")
          end
          :claimed
        rescue Errno::EEXIST
          :already_opened
        rescue StandardError => e
          warn("[react_on_rails] Could not write browser-opened marker: #{e.message}")
          :untracked
        end

        def clear_browser_open_once_marker_if_claimed(marker_state)
          return unless marker_state == :claimed

          File.delete(OPEN_BROWSER_ONCE_MARKER)
        rescue Errno::ENOENT
          nil
        rescue StandardError => e
          warn("[react_on_rails] Could not remove browser-opened marker: #{e.message}")
          nil
        end

        def box_border(width)
          "┌#{'─' * (width - 2)}┐"
        end

        def box_bottom(width)
          "└#{'─' * (width - 2)}┘"
        end

        def parse_cli_options(args)
          options = default_cli_options
          build_option_parser(options).parse!(args)
          options
        end

        def default_cli_options
          {
            route: nil,
            rails_env: nil,
            verbose: false,
            skip_database_check: false,
            test_watch_mode: "auto",
            open_browser: false,
            open_browser_once: false
          }
        end

        def build_option_parser(options)
          OptionParser.new do |opts|
            opts.banner = "Usage: dev [command] [options]"
            register_cli_flag_options(opts, options)
            register_browser_cli_options(opts, options)
            register_help_option(opts)
          end
        end

        def register_cli_flag_options(opts, options)
          opts.on("--route ROUTE", "Specify the route to display in URLs (default: root)") do |route|
            options[:route] = route
          end

          opts.on("--rails-env ENV", "Override RAILS_ENV for assets:precompile step only (prod mode only)") do |env|
            options[:rails_env] = env
          end

          opts.on("-v", "--verbose", "Enable verbose output for pack generation") do
            options[:verbose] = true
          end

          opts.on("--skip-database-check", "Skip database connectivity check (saves ~1-2s startup time)") do
            options[:skip_database_check] = true
          end

          opts.on("--test-watch-mode MODE", "For `bin/dev test-watch`: auto (default), full, or client-only") do |mode|
            options[:test_watch_mode] = mode
          end
        end

        def register_browser_cli_options(opts, options)
          # OptionParser applies flags left-to-right, so later browser flags intentionally
          # override earlier ones when callers pass multiple variants together.
          opts.on("--open-browser", "Open the app in your browser once the server is reachable") do
            options[:open_browser] = true
            options[:open_browser_once] = false
          end

          opts.on("--open-browser-once",
                  "Open the app in your browser after the first successful boot only") do
            options[:open_browser_once] = true
            options[:open_browser] = false
          end

          opts.on("--no-open-browser", "Disable automatic browser opening for this run") do
            options[:open_browser] = false
            options[:open_browser_once] = false
          end
        end

        def register_help_option(opts)
          opts.on("-h", "--help", "Prints this help") do
            show_help
            exit
          end
        end

        def box_empty_line(width)
          "│#{' ' * (width - 2)}│"
        end

        def format_box_line(content, box_width)
          line = "│ #{content}"
          # Use visual length for colored text
          visual_length = Rainbow.uncolor(line).length
          padding = box_width - visual_length - 2
          line + "#{' ' * padding}│"
        end

        # rubocop:disable Metrics/AbcSize
        def help_troubleshooting
          <<~TROUBLESHOOTING
            #{Rainbow('🔧 TROUBLESHOOTING:').cyan.bold}

            #{Rainbow('⚛️  React Refresh Issues:').yellow.bold}
            #{Rainbow('If you see "$RefreshSig$ is not defined" errors:').white}
            #{Rainbow('1.').green} #{Rainbow('Check that both babel plugin and webpack plugin are configured:').white}
               #{Rainbow('•').yellow} #{Rainbow('babel.config.js: \'react-refresh/babel\' plugin (enabled when WEBPACK_SERVE=true)').white}
               #{Rainbow('•').yellow} #{Rainbow('config/webpack/development.js: ReactRefreshWebpackPlugin (enabled when WEBPACK_SERVE=true)').white}
            #{Rainbow('2.').green} #{Rainbow('Ensure you\'re running HMR mode:').white} #{Rainbow('bin/dev').green.bold} #{Rainbow('(not').white} #{Rainbow('bin/dev static').red}#{Rainbow(')').white}
            #{Rainbow('3.').green} #{Rainbow('Try restarting the development server:').white} #{Rainbow('bin/dev kill && bin/dev').green.bold}
            #{Rainbow('4.').green} #{Rainbow('Note: React Refresh only works in HMR mode, not static mode').white}

            #{Rainbow('🚨 General Issues:').yellow.bold}
            #{Rainbow('•').red} #{Rainbow('"Port already in use"').white} #{Rainbow('→ Run:').yellow} #{Rainbow('bin/dev kill').green.bold}
            #{Rainbow('•').red} #{Rainbow('"Webpack compilation failed"').white} #{Rainbow('→ Check console for specific errors').white}
            #{Rainbow('•').red} #{Rainbow('"Process manager not found"').white} #{Rainbow('→ Install:').yellow} #{Rainbow('brew install overmind').green.bold} #{Rainbow('(or').white} #{Rainbow('gem install foreman').green.bold}#{Rainbow(')').white}
            #{Rainbow('•').red} #{Rainbow('"Assets not loading"').white} #{Rainbow('→ Verify Procfile.dev is present and check server logs').white}

            #{Rainbow('📖 DOCUMENTATION:').cyan.bold}
            #{Rainbow('•').yellow} #{Rainbow('Testing & dev server guide:').white} #{Rainbow('docs/oss/building-features/dev-server-and-testing.md').green}
            #{Rainbow('•').yellow} #{Rainbow('Testing configuration:').white} #{Rainbow('docs/oss/building-features/testing-configuration.md').green}
            #{Rainbow('•').yellow} #{Rainbow('Full docs:').white} #{Rainbow('https://reactonrails.com/docs/').cyan.underline}
          TROUBLESHOOTING
        end
        # rubocop:enable Metrics/AbcSize
      end
    end
  end
end