#!/usr/bin/env ruby
require "optparse"
require "json"
require "net/http"
require "fileutils"
require "etc"
require "time"

options = {
  users: 3000, ramp: 480, hold: 120, cpus: 2, memory: "2g",
  send_interval: 1, sample: 25, build: true, port: 3000, image: "campfire-bench"
}

OptionParser.new do |parser|
  parser.on("--label LABEL", "Run label (required), e.g. redis-2cpu-2g") { |v| options[:label] = v }
  parser.on("--users N", Integer, "Ramp target (default 3000, max 10000)") { |v| options[:users] = v }
  parser.on("--ramp SECONDS", Integer, "Ramp duration (default 480)") { |v| options[:ramp] = v }
  parser.on("--hold SECONDS", Integer, "Hold duration at target (default 120)") { |v| options[:hold] = v }
  parser.on("--cpus N", Integer, "App container CPU count via cpuset (default 2)") { |v| options[:cpus] = v }
  parser.on("--memory SIZE", "App container memory limit (default 2g)") { |v| options[:memory] = v }
  parser.on("--send-interval S", Float, "Seconds between messages (default 1)") { |v| options[:send_interval] = v }
  parser.on("--sample N", Integer, "1-in-N receivers log latency lines (default 25)") { |v| options[:sample] = v }
  parser.on("--port PORT", Integer, "Host port for the app (default 3000)") { |v| options[:port] = v }
  parser.on("--image TAG", "Image tag (default campfire-bench)") { |v| options[:image] = v }
  parser.on("--no-build", "Skip docker build") { options[:build] = false }
end.parse!

abort "--label is required" unless options[:label]

class Bench
  APP_CONTAINER = "campfire-bench-app"
  K6_CONTAINER = "campfire-bench-k6"

  attr_reader :opts, :results_dir

  def initialize(opts)
    @opts = opts
    @root = File.expand_path("..", __dir__)
    @perf_dir = File.join(@root, "test/performance")
    @results_dir = File.join(@root, "tmp/bench", opts[:label])
  end

  def run
    FileUtils.mkdir_p(results_dir)
    build if opts[:build]
    write_meta
    start_app
    wait_for_app
    sampler = start_stats_sampler
    run_k6
  ensure
    stop(sampler)
    capture_app_state
    cleanup
  end

  private
    def build
      sh "docker", "build", "-t", opts[:image], @root
    end

    def write_meta
      meta = opts.merge(
        git_revision: `git rev-parse HEAD`.strip,
        git_branch: `git rev-parse --abbrev-ref HEAD`.strip,
        host_cpus: Etc.nprocessors,
        started_at: Time.now.utc.iso8601
      )
      File.write(File.join(results_dir, "meta.json"), JSON.pretty_generate(meta))
    end

    def start_app
      system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
      sh "docker", "run", "-d", "--name", APP_CONTAINER,
         "--cpuset-cpus", app_cpuset,
         "--memory", opts[:memory], "--memory-swap", opts[:memory],
         "-p", "#{opts[:port]}:80",
         "-e", "SECRET_KEY_BASE=dummy",
         "-e", "RAILS_ENV=performance",
         opts[:image]
    end

    def app_cpuset
      (0...opts[:cpus]).to_a.join(",")
    end

    def wait_for_app
      timeout_at = Time.now + 300
      uri = URI("http://127.0.0.1:#{opts[:port]}/")
      loop do
        raise "App not up after 300s" if Time.now > timeout_at
        begin
          break if Net::HTTP.get_response(uri).code.to_i < 400
        rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError
        end
        puts "Waiting for app (seeding 10k users on first boot)..."
        sleep 2
      end
      puts "App is up."
    end

    def start_stats_sampler
      stats_path = File.join(results_dir, "stats.csv")
      fork do
        File.open(stats_path, "w") do |file|
          file.puts "epoch_ms,cpu_perc,mem_bytes"
          file.sync = true
          loop do
            line = `docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}}" #{APP_CONTAINER} 2>/dev/null`.strip
            if line =~ /([\d.]+)%\s+([\d.]+)(\w+)/
              file.puts "#{(Time.now.to_f * 1000).to_i},#{$1},#{to_bytes($2.to_f, $3)}"
            end
            sleep 1
          end
        end
      end
    end

    def to_bytes(value, unit)
      factors = { "B" => 1, "KiB" => 1024, "MiB" => 1024**2, "GiB" => 1024**3,
                  "kB" => 1000, "MB" => 1000**2, "GB" => 1000**3 }
      (value * factors.fetch(unit, 1)).to_i
    end

    def run_k6
      total = opts[:ramp] + opts[:hold]
      puts "Running k6: ramp to #{opts[:users]} users over #{opts[:ramp]}s, hold #{opts[:hold]}s (total #{total}s)..."
      system "docker", "rm", "-f", K6_CONTAINER, err: File::NULL, out: File::NULL
      k6_log = File.join(results_dir, "k6.log")
      summary = File.join(results_dir, "summary.json")
      finished = system(
        "docker", "run", "--rm", "--name", K6_CONTAINER,
        "--network", "host",
        "--cpuset-cpus", k6_cpuset,
        "--ulimit", "nofile=262144:262144",
        "-u", "#{Process.uid}:#{Process.gid}",
        "-v", "#{@perf_dir}:/src",
        "-v", "#{results_dir}:/results",
        "-e", "HOST=127.0.0.1", "-e", "PORT=#{opts[:port]}",
        "-e", "USERS=#{opts[:users]}",
        "-e", "RAMP_S=#{opts[:ramp]}", "-e", "HOLD_S=#{opts[:hold]}",
        "-e", "SEND_INTERVAL_S=#{opts[:send_interval]}",
        "-e", "SAMPLE=#{opts[:sample]}",
        "grafana/k6", "run",
        "--summary-export", "/results/summary.json",
        "--quiet",
        "/src/ramp.js",
        out: k6_log, err: [ k6_log, "a" ]
      )
      if finished
        puts "k6 finished."
      else
        puts "k6 exited non-zero — results still captured in #{k6_log}."
      end
    end

    def k6_cpuset
      (opts[:cpus]...Etc.nprocessors).to_a.join(",")
    end

    def stop(sampler)
      Process.kill("TERM", sampler)
      Process.wait(sampler)
    rescue Errno::ESRCH, Errno::ECHILD
      nil
    end

    def capture_app_state
      oom = `docker inspect --format '{{.State.OOMKilled}}' #{APP_CONTAINER} 2>/dev/null`.strip
      File.write(File.join(results_dir, "oom.txt"), oom)
      system "docker logs --tail 200 #{APP_CONTAINER} > #{File.join(results_dir, 'app.log')} 2>&1"
      puts "App container was OOM-killed during the run." if oom == "true"
    end

    def cleanup
      system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
    end

    def sh(*cmd)
      puts "+ #{cmd.join(' ')}"
      system(*cmd) || abort("Command failed: #{cmd.join(' ')}")
    end
end

Bench.new(options).run
puts "Results in tmp/bench/#{options[:label]}/"
