# Channel busy survey through the rXg: runs busy-check.sh on the APs and
# reports what their survey counters say, site-wide. Read-only.
#
# busy/active alone (what the fleet check shows) cannot tell client load from
# background energy: on the EAP111 2.4 GHz radio it reads ~57% even idle, and
# a stream filling a third of the airtime moved it only ~5 points. So this
# splits busy into receive (any network), own-network receive, transmit and
# the undecoded remainder, plus the noise floor. Own-network receive +
# transmit is the AP's real client airtime. Split by AP model, because
# drivers report differently: MediaTek counts background energy as receive,
# while Qualcomm AP radios report no receive or own-network time at all, so
# their whole busy figure lands in "undecoded" -- compare their busy level,
# not their breakdown.
#
# On the rXg, as root:
#   curl -o /tmp/busy-check.sh https://pre-alpha.henryhaller.com/busy-check.sh
#   curl -o /tmp/busy-check.rb https://pre-alpha.henryhaller.com/busy-check.rb
#   cd /space/rxg/console && bin/rails runner /tmp/busy-check.rb
#
#   (no AP=)      every online openwifi_wss AP; LIMIT=n, ZONE=id narrow it
#   AP=name,...   just these APs (name or id), full per-window output
#   SAMPLES=5     windows per AP
#   INTERVAL=2    seconds per window
#
# The script runs detached on each AP and a second command collects it:
# rg_wss_client runs rXg shell commands synchronously, so running it inline
# would stall every AP's client for the whole sampling time.

ARGV.each do |arg|
  key, value = arg.split('=', 2)
  ENV[key] ||= value if value && key.match?(/\A[A-Z][A-Z0-9_]*\z/)
end

script   = File.read(ENV['SCRIPT'] || '/tmp/busy-check.sh')
samples  = (ENV['SAMPLES'] || 5).to_i
interval = (ENV['INTERVAL'] || 2).to_i
wanted   = ENV['AP'].to_s.split(',').map(&:strip).reject(&:empty?)
OUTF     = '/tmp/busy-check.out'
TSV      = "/tmp/busy-check-#{Time.current.strftime('%Y%m%d-%H%M%S')}.tsv"

aps =
  if wanted.any?
    wanted.filter_map do |w|
      ap = (AccessPoint.find_by(id: w.to_i) if w.match?(/\A\d+\z/)) || AccessPoint.find_by(name: w)
      warn "no AP named #{w}" unless ap
      ap
    end
  else
    scope = AccessPoint.joins(:infrastructure_device)
                       .where(infrastructure_devices: { device: 'openwifi_wss' }).where(online: true)
    scope = scope.where(access_point_zone_id: ENV['ZONE']) if ENV['ZONE'].present?
    scope = scope.limit(ENV['LIMIT'].to_i) if ENV['LIMIT'].present?
    scope.order(:id).to_a
  end
abort 'no APs matched' if aps.empty?

# Send one command to each AP, paced like the fleet check; return ap => text.
def run_on(aps, cmd, wait)
  ce = {}
  aps.each_slice(25).with_index do |slice, i|
    slice.each do |ap|
      ce[ap] = CommandExecution.create!(source: ap, command: cmd, created_by: 'busy_check')
    rescue StandardError => e
      warn "  dispatch failed for #{ap.name}: #{e.class}: #{e.message}"
    end
    sleep 2 if (i + 1) * 25 < aps.size
  end
  ids = ce.values.map(&:id)
  deadline = Time.current + wait
  sleep 2 until CommandExecution.where(id: ids).where.not(exit_code: nil).count >= ids.size ||
                Time.current > deadline
  res = CommandExecution.where(id: ids).index_by(&:id)
  ce.to_h { |ap, c| [ap, (r = res[c.id]) && !r.exit_code.nil? ? r.result_text.to_s : nil] }
end

mode = wanted.any? ? 'text' : 'raw'
launch = "rm -f #{OUTF} #{OUTF}.tmp\n" \
         "{ ( set -- #{samples} #{interval} #{mode}\n#{script}\n) > #{OUTF}.tmp 2>&1; " \
         "mv #{OUTF}.tmp #{OUTF}; } </dev/null >/dev/null 2>&1 &\necho BUSYLAUNCHED"
collect = "if [ -f #{OUTF} ]; then echo BUSYOUT; cat #{OUTF}; rm -f #{OUTF}; " \
          "elif [ -f #{OUTF}.tmp ]; then echo BUSYPENDING; else echo BUSYNONE; fi"

started = Time.current
warn "Starting a #{samples} x #{interval}s survey on #{aps.size} AP(s)..."
launched = run_on(aps, launch, 60).select { |_, t| t.to_s.include?('BUSYLAUNCHED') }.keys
warn "  started on #{launched.size}/#{aps.size}"
sleep [(started + (aps.size / 25.0).ceil * 2 + samples * interval + 5 - Time.current).ceil, 0].max
out = {}
2.times do |attempt|
  todo = launched.reject { |ap| out[ap] }
  break if todo.empty?
  run_on(todo, collect, 60).each do |ap, t|
    out[ap] = t.sub(/\A.*?BUSYOUT\s*/m, '') if t&.include?('BUSYOUT')
  end
  sleep 10 if attempt.zero? && launched.any? { |ap| out[ap].nil? }
end

if wanted.any?
  aps.each do |ap|
    puts "=== #{ap.name} (id #{ap.id})"
    puts out[ap] || '  (no result)'
    puts
  end
  exit 0
end

# Site-wide: average each AP's windows per band, then summarise across APs.
BANDS = { 2 => '2.4 GHz', 5 => '5 GHz' }.freeze
KEYS  = %i[busy rx own tx und noise].freeze
rows = out.filter_map do |ap, text|
  w = Hash.new { |h, k| h[k] = [] }
  sta = nil
  text.each_line do |l|
    f = l.split
    if f[0] == 'W' && f.size >= 9
      w[f[2].to_i] << KEYS.zip(f[3, 6].map(&:to_f)).to_h
    elsif f[0] == 'S'
      sta = f[1].to_i
    end
  end
  next if w.empty?
  avg = w.transform_values do |list|
    KEYS.to_h do |k|
      # -1 marks a counter the driver lacks; a noise floor is negative, and 0 when absent.
      v = list.map { |x| x[k] }
      v = k == :noise ? v.reject(&:zero?) : v.reject(&:negative?)
      [k, v.empty? ? nil : (v.sum / v.size).round(1)]
    end
  end
  avg.each_value { |a| a[:air] = a[:own] && a[:tx] ? (a[:own] + a[:tx]).round(1) : nil }
  { ap: ap, model: ap.model.presence || '(unknown)', sta: sta, b: avg }
end

pctl = lambda do |vals, q|
  v = vals.compact.sort
  v.empty? ? nil : v[[(v.size * q).ceil - 1, 0].max]
end
fmt = ->(x) { x.nil? ? '-' : format('%.1f', x) }

puts
puts "=== Channel busy survey: #{rows.size}/#{aps.size} AP(s) reported (#{samples} windows of #{interval}s) ==="
puts
METRICS = [[:busy, 'busy'], [:rx, 'receive, any network'], [:own, 'receive, own network'],
           [:tx, 'transmit'], [:air, 'own-network airtime (own rx + tx)'],
           [:und, 'undecoded (busy - rx - tx)'], [:noise, 'noise floor (dBm)']].freeze
BANDS.each do |b, label|
  rb = rows.select { |r| r[:b][b] }
  next if rb.empty?
  puts "#{label}  (#{rb.size} radios)                     p10   median      p90"
  METRICS.each do |k, l|
    v = rb.map { |r| r[:b][b][k] }
    puts format('  %-36s %7s %8s %8s', l, fmt.(pctl.(v, 0.1)), fmt.(pctl.(v, 0.5)), fmt.(pctl.(v, 0.9)))
  end
  puts "  by model                                busy   own-net air  undecoded  noise   radios"
  rb.group_by { |r| r[:model] }.sort_by { |_, g| -g.size }.each do |m, g|
    med = ->(k) { fmt.(pctl.(g.map { |r| r[:b][b][k] }, 0.5)) }
    puts format('    %-34s %7s %12s %10s %6s %8d', m.to_s[0, 34], med.(:busy), med.(:air), med.(:und), med.(:noise), g.size)
  end
  puts '  highest own-network airtime:'
  rb.sort_by { |r| -(r[:b][b][:air] || 0) }.first(5).each do |r|
    a = r[:b][b]
    puts format('    %-32s air %5s%%  busy %5s%%  undecoded %5s%%  stations %s', r[:ap].name.to_s[0, 32],
                fmt.(a[:air]), fmt.(a[:busy]), fmt.(a[:und]), r[:sta] || '-')
  end
  puts '  highest undecoded share:'
  rb.sort_by { |r| -(r[:b][b][:und] || 0) }.first(5).each do |r|
    a = r[:b][b]
    puts format('    %-32s undecoded %5s%%  busy %5s%%  noise %s dBm', r[:ap].name.to_s[0, 32],
                fmt.(a[:und]), fmt.(a[:busy]), fmt.(a[:noise]))
  end
  puts
end
puts 'Read busy against own-network airtime: busy far above airtime on quiet APs is'
puts 'background, not client load. MediaTek counts that background as receive.'
puts 'Qualcomm AP radios report no receive time, so all their busy shows as'
puts 'undecoded and no airtime: compare their busy level, not their breakdown.'

File.open(TSV, 'w') do |io|
  io.puts %w[ap_id name model stations band busy rx own tx airtime undecoded noise].join("\t")
  rows.each do |r|
    r[:b].each do |b, a|
      io.puts [r[:ap].id, r[:ap].name, r[:model], r[:sta], b, *%i[busy rx own tx air und noise].map { |k| a[k] }].join("\t")
    end
  end
end
puts
puts "Per-AP detail: #{TSV}"
