# What the rXg recorded about some devices in a time window: RADIUS sign-ons
# (with the VLAN, session timer and switch port each one carried), DHCP
# messages, and syslog lines from APs and switches that mention them. For
# reviewing a reported incident after the AP log rings have rolled over.
#
# Usage, as root on the rXg (times are the rXg's local time zone):
#   curl -o /tmp/window-dump.rb https://pre-alpha.henryhaller.com/window-dump.rb
#   cd /space/rxg/console && bin/rails runner /tmp/window-dump.rb \
#     FROM="2026-10-14 18:20" TO="2026-10-14 19:00" MACS=<mac>,<mac>,... VLAN=<id> SENDER=<AP name>
#
#   MACS       devices to follow (any MAC format)
#   VLAN=N     also show syslog lines about that VLAN's interfaces (-vN, vlan N)
#   SENDER=s   also show every syslog line from APs whose name contains s (APs log
#              under their WAN MAC), or from any sender whose hostname contains s
#   LINES_MAX  syslog lines printed (default 400)

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

def norm_mac(m) = m.to_s.downcase.gsub(/[^0-9a-f]/, '')

def mac_variants(macs)
  macs.flat_map do |m|
    n = norm_mac(m)
    next [] if n.length != 12
    colon = n.scan(/../).join(':')
    [n, colon, colon.upcase, n.scan(/../).join('-'), n.scan(/../).join('-').upcase]
  end.uniq
end

FROM = Time.zone.parse(ENV['FROM'].to_s)
TO   = Time.zone.parse(ENV['TO'].to_s)
abort 'FROM= and TO= are required, e.g. FROM="2026-10-14 18:20" TO="2026-10-14 19:00"' unless FROM && TO
MACS      = ENV['MACS'].to_s.split(',').map(&:strip).reject(&:empty?)
VLAN      = ENV['VLAN'].presence
SENDER    = ENV['SENDER'].presence
LINES_MAX = (ENV['LINES_MAX'] || 400).to_i
VARIANTS  = mac_variants(MACS)
abort 'MACS= needs at least one valid MAC' if VARIANTS.empty? && !SENDER && !VLAN
SENDER_APS = SENDER ? AccessPoint.where('name ILIKE ?', "%#{SENDER}%").pluck(:name, :mac) : []

def t(at) = at&.in_time_zone&.strftime('%m-%d %H:%M:%S')
def attr(yaml, name) = yaml.to_s[/#{Regexp.escape(name)}\W{0,40}([A-Za-z0-9_.:\/-]+)/, 1]

puts "Window #{FROM.strftime('%Y-%m-%d %H:%M')} to #{TO.strftime('%H:%M')} #{Time.zone.tzinfo.name}; " \
     "#{MACS.size} MAC(s)#{VLAN ? ", VLAN #{VLAN}" : ''}#{SENDER ? ", sender ~ #{SENDER}" : ''}"
SENDER_APS.first(10).each { |name, mac| puts "  sender AP: #{name} (#{mac}, logs as #{norm_mac(mac)})" }
puts

if VARIANTS.any?
  cols = RadiusServerLog.column_names
  rows = RadiusServerLog.where(created_at: FROM..TO, calling_station_id_mac: VARIANTS).order(:created_at)
  puts "RADIUS  (#{rows.count} row(s))"
  if rows.any?
    puts format('  %-14s %-17s %-15s %5s %7s %-18s %s', 'TIME', 'MAC', 'RESPONSE', 'VLAN', 'TIMEOUT', 'TERMINATION', 'FROM')
    rows.each do |r|
      rq = cols.include?('request_attributes_yaml') ? r.request_attributes_yaml : nil
      rs = r.response_attributes_yaml
      from = [attr(rq, 'NAS-Identifier') || attr(rq, 'NAS-IP-Address'), attr(rq, 'NAS-Port-Id')].compact.join(' ')
      puts format('  %-14s %-17s %-15s %5s %7s %-18s %s', t(r.created_at), r.calling_station_id_mac,
                  r.response_type || '-', attr(rs, 'Tunnel-Private-Group-Id') || '-',
                  attr(rs, 'Session-Timeout') || '-', attr(rs, 'Termination-Action') || '-', from)
    end
  end
  puts

  cols = DhcpMessage.column_names
  want = %w[created_at mac message_type ip hostname via error] & cols
  rows = DhcpMessage.where(created_at: FROM..TO, mac: VARIANTS).order(:created_at).pluck(*want)
  puts "DHCP  (#{rows.size} message(s))"
  if rows.any?
    puts '  ' + want.map(&:upcase).join('  ')
    rows.each { |r| puts '  ' + r.map { |v| v.is_a?(Time) ? t(v) : v.to_s }.join('  ') }
  end
  puts
end

scope = SyslogReceiverLog.where(received_at: FROM..TO)
total = scope.count
pats = VARIANTS.select { |v| v.include?(':') }.map { |v| "%#{v}%" }
pats += ["%-v#{VLAN} %", "%-v#{VLAN}:%", "%(vlan #{VLAN})%", "%vlan=#{VLAN} %", "%vlan #{VLAN} %"] if VLAN
conds = pats.map { 'message ILIKE ?' }
args = pats.dup
if SENDER
  conds << 'hostname ILIKE ?'
  args << "%#{SENDER}%"
  SENDER_APS.each do |_, mac|
    conds << 'hostname ILIKE ?'
    args << "%#{norm_mac(mac)}%"
  end
end
hits = conds.any? ? scope.where(conds.join(' OR '), *args) : scope.none
n = hits.count
puts "Syslog  (#{total} line(s) received in the window from #{scope.distinct.count(:hostname)} sender(s); #{n} match)"
if total.zero?
  puts '  nothing received -- APs and switches are probably not sending syslog to this rXg'
elsif n.zero?
  puts '  busiest senders in the window:'
  scope.group(:hostname).order(Arel.sql('count(*) DESC')).limit(10).count.each { |h, c| puts format('    %-40s %d', h, c) }
else
  hits.order(:received_at).limit(LINES_MAX).pluck(:received_at, :hostname, :tag, :message).each do |at, h, tag, msg|
    puts format('  %-14s %-22s %-14s %s', t(at), h.to_s[0, 22], tag.to_s[0, 14], msg.to_s.gsub(/\s+/, ' ')[0, 220])
  end
  puts "  ... #{n - LINES_MAX} more; raise LINES_MAX= or narrow the window" if n > LINES_MAX
end
