# Re-program plain-VLAN sdan tunnels into the AP switch VLAN table, fleet-wide.
#
# WHY: on APs whose uplink is behind a swconfig switch (WF-186h), rg_wss_client
# writes each per-unit VLAN into the switch once, when a client first uses it.
# Every uCentral config apply rebuilds /etc/config/network from a baseline with
# no such VLANs and reloads the switch, so every unit VLAN used before the apply
# goes dark. rg_wss_client (<= 1.0.40) trusts its tunnel cache and never
# re-programs them. This puts them back without restarting anything.
#
# WHAT IT DOES on each AP (BusyBox ash, see SHELL below):
#   - only ADDS switch_vlan sections, "<wan>t <cpu>t", for VLANs that have a
#     plain-VLAN sdan tunnel and no section -- the exact form rg_wss_client
#     writes; existing sections are never edited or removed
#   - only on a switch whose WAN port capabilities.json names (the client's own
#     rule); APs without one (188n, 189, 660a) report switches=0 and are skipped
#   - one `swconfig load` per changed switch: one brief switch reset per AP,
#     where a client restart would reset it once per VLAN as clients return
#   - skips the AP if a config apply is running
#
# It does NOT stop the next config push from wiping them again. Until
# rg_wss_client re-programs after every apply, hold config pushes to the
# affected APs, or re-run this after each one.
#
# USAGE -- on the rXg, as root (csh-safe; settings are trailing arguments):
#
#   curl -o /tmp/sdan-switch-repair.rb https://pre-alpha.henryhaller.com/sdan-switch-repair.rb
#   cd /space/rxg/console
#   bin/rails runner /tmp/sdan-switch-repair.rb                  # report only
#   bin/rails runner /tmp/sdan-switch-repair.rb APPLY=1 LIMIT=3  # repair 3 APs
#   bin/rails runner /tmp/sdan-switch-repair.rb APPLY=1          # repair all
#
#   APPLY=1       repair (otherwise report only)
#   LIMIT=n       cap how many APs are repaired (report still covers all)
#   ZONE=id       restrict to one access point zone
#   BATCH=10      APs repaired at once (default 10)
#   INTERVAL=15   seconds between batches (default 15)
#   TIMEOUT=120   seconds to wait for each round of replies (default 120)
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

APPLY    = ENV['APPLY'] == '1'
BATCH    = (ENV['BATCH'] || 10).to_i.clamp(1, 50)
INTERVAL = (ENV['INTERVAL'] || 15).to_i
TIMEOUT  = (ENV['TIMEOUT'] || 120).to_i
OUT      = "/tmp/sdan-switch-repair-#{Time.current.strftime('%Y%m%d-%H%M%S')}.tsv"

SHELL = <<~'SH'
  mode=${1:-report}
  CTL=/usr/sbin/rxg_sdanctl
  CAPS=/etc/ucentral/capabilities.json
  status=ok
  if ps w 2>/dev/null | grep -v grep | grep -q '[u]central[.]uc'; then
    echo "SWREPAIR status=busy-config-apply"; exit 0
  fi
  [ -x /sbin/swconfig ] || { echo "SWREPAIR status=no-swconfig"; exit 0; }
  vt=$($CTL tunnels 2>/dev/null | awk '{t=0; v=""; for(i=1;i<=NF;i++){ if($i ~ /^vlan=/) v=substr($i,6); if($i=="type=vlan") t=1 } if(t && v!="") print v}')
  out=""
  tot_add=0; tot_hwmiss=0; tot_after=0; nsw=0
  for sw in $(swconfig list 2>/dev/null | awk '{print $2}'); do
    wan=$(jsonfilter -i $CAPS -e "@.switch_ports[@.name='$sw'].wan[0]" 2>/dev/null)
    cpu=$(swconfig dev $sw help 2>/dev/null | sed -n 's/.*cpu @ *\([0-9]*\).*/\1/p' | head -1)
    [ -z "$cpu" ] && cpu=$(jsonfilter -i $CAPS -e "@.switch_ports[@.name='$sw'].port" 2>/dev/null)
    [ -n "$wan" ] && [ -n "$cpu" ] || continue
    nsw=$((nsw+1))
    hw=$(swconfig dev $sw show 2>/dev/null | awk '$1=="VLAN"{v=$2; sub(/:$/,"",v)} $1=="vid:"{v=$2} $1=="ports:" && NF>1 && v!=""{print v}')
    uv=$(uci -q show network | awk -F= -v sw="$sw" '
      $1 ~ /[.]device$/ { s=$1; sub(/^network[.]/,"",s); sub(/[.]device$/,"",s); d=$2; gsub(/'"'"'/,"",d); dev[s]=d }
      $1 ~ /[.]vlan$/ { s=$1; sub(/^network[.]/,"",s); sub(/[.]vlan$/,"",s); v=$2; gsub(/[^0-9]/,"",v); vl[s]=v }
      END { for (s in vl) if (dev[s]==sw) print vl[s] }')
    add=0; hwmiss=0
    for v in $vt; do
      echo "$hw" | grep -qx "$v" || hwmiss=$((hwmiss+1))
      if ! echo "$uv" | grep -qx "$v"; then
        add=$((add+1))
        if [ "$mode" = apply ]; then
          sect=$(uci add network switch_vlan) && \
          uci set network.$sect.device=$sw && uci set network.$sect.vlan=$v && \
          uci set network.$sect.ports="${wan}t ${cpu}t" || status=uci-error
        fi
      fi
    done
    after=$hwmiss
    if [ "$mode" = apply ] && [ $((add+hwmiss)) -gt 0 ] && [ "$status" = ok ]; then
      uci commit network || status=commit-error
      [ "$status" = ok ] && { swconfig dev $sw load network || status=load-error; }
      hw2=$(swconfig dev $sw show 2>/dev/null | awk '$1=="VLAN"{v=$2; sub(/:$/,"",v)} $1=="vid:"{v=$2} $1=="ports:" && NF>1 && v!=""{print v}')
      after=0; for v in $vt; do echo "$hw2" | grep -qx "$v" || after=$((after+1)); done
    fi
    out="$out $sw:wan=$wan,cpu=$cpu,add=$add,hwmiss=$hwmiss,after=$after"
    tot_add=$((tot_add+add)); tot_hwmiss=$((tot_hwmiss+hwmiss)); tot_after=$((tot_after+after))
  done
  ntun=$(echo "$vt" | grep -c '[0-9]')
  echo "SWREPAIR status=$status mode=$mode switches=$nsw tunnels=$ntun add=$tot_add hwmiss=$tot_hwmiss after=$tot_after detail=${out# }"
SH

def command_for(mode)
  "set -- #{mode}\n#{SHELL}"
end

# Dispatch `command` to `aps`, wait for replies, return { ap => fields }.
def run_on(aps, command, label)
  warn "#{label}: #{aps.size} AP(s), waiting up to #{TIMEOUT}s..."
  ce_for = {}
  aps.each do |ap|
    ce_for[ap] = CommandExecution.create!(source: ap, command: command,
                                          created_by: 'sdan_switch_repair')
  rescue StandardError => e
    warn "  dispatch failed for #{ap.name}: #{e.class}: #{e.message}"
  end
  ids = ce_for.values.map(&:id)
  deadline = Time.current + TIMEOUT
  until CommandExecution.where(id: ids).where.not(exit_code: nil).count >= ids.size ||
        Time.current >= deadline
    sleep 3
  end
  done = CommandExecution.where(id: ids).index_by(&:id)
  aps.to_h do |ap|
    ce = ce_for[ap]
    text = ce && (done[ce.id] || ce).result_text.to_s
    i = text.to_s.index('SWREPAIR')
    [ap, i ? text[i..].split("\n").first.scan(/(\w+)=(\S+)/).to_h : { 'status' => ce ? 'no-reply' : 'dispatch-failed' }]
  end
end

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?
aps = scope.order(:id).to_a
abort 'No online openwifi_wss APs matched.' if aps.empty?

report = {}
aps.each_slice(50) { |slice| report.merge!(run_on(slice, command_for('report'), 'Report')) }

needs = report.select { |_, f| f['add'].to_i.positive? || f['hwmiss'].to_i.positive? }.keys
with_sw = report.count { |_, f| f['switches'].to_i.positive? }
puts
puts "=== Switch VLAN repair: #{APPLY ? 'APPLY' : 'report only'} ==="
puts format('  %-40s %6d', 'online APs checked', aps.size)
puts format('  %-40s %6d', 'with a switch in the uplink path', with_sw)
puts format('  %-40s %6d', 'needing repair', needs.size)
puts format('  %-40s %6d', 'VLANs missing from switch hardware', needs.sum { |ap| report[ap]['hwmiss'].to_i })
puts format('  %-40s %6d', 'VLAN sections to add', needs.sum { |ap| report[ap]['add'].to_i })
report.values.map { |f| f['status'] }.tally.reject { |s, _| s == 'ok' }.each do |s, n|
  puts format('  %-40s %6d', "status #{s}", n)
end
puts

results = {}
if APPLY && needs.any?
  targets = ENV['LIMIT'].present? ? needs.first(ENV['LIMIT'].to_i) : needs
  targets.each_slice(BATCH).with_index do |slice, i|
    sleep INTERVAL if i.positive?
    results.merge!(run_on(slice, command_for('apply'), "Repair batch #{i + 1}"))
  end
  fixed  = results.count { |_, f| f['status'] == 'ok' && f['after'].to_i.zero? }
  failed = results.reject { |_, f| f['status'] == 'ok' && f['after'].to_i.zero? }
  puts format('  %-40s %6d', 'APs repaired', results.size)
  puts format('  %-40s %6d', '..all VLANs now in hardware', fixed)
  puts format('  %-40s %6d', 'VLANs restored', results.sum { |_, f| f['hwmiss'].to_i - f['after'].to_i })
  puts format('  %-40s %6d', '..still missing / failed', failed.size)
  failed.first(15).each do |ap, f|
    puts format('    %-30s status=%s after=%s', ap.name.to_s[0, 30], f['status'], f['after'])
  end
  puts
elsif needs.any?
  puts 'Report only. Re-run with APPLY=1 (try LIMIT=3 first) to repair.'
  puts
end

File.open(OUT, 'w') do |io|
  io.puts %w[ap_id name model status switches tunnels add hwmiss after_repair repair_status detail].join("\t")
  report.each do |ap, f|
    r = results[ap] || {}
    io.puts [ap.id, ap.name, ap.model, f['status'], f['switches'], f['tunnels'], f['add'],
             f['hwmiss'], r['after'], r['status'], (r['detail'] || f['detail'])].join("\t")
  end
end
puts "Per-AP detail: #{OUT}"
