///|
/// End-to-end notification harness: the agent -> manager direction.
///
/// Two modes, because the two roles need different setups:
///
///   moon run e2e/notify --target js -- send  
///     Send notifications to a real receiver (snmptrapd) and check that
///     the acknowledged kind really was acknowledged. Run the receiver
///     with:
///       docker run -d --name trapd -p 16162:162/udp moon-snmp-e2e \
///         snmptrapd -f -Lo -n -c /etc/snmp/snmptrapd.conf
///
///   moon run e2e/notify --target js -- recv  
///     Listen for `count` notifications and check each one. Point a
///     real sender at it with snmptrap, which is what CI does.
///
/// The same two roles over v3, where the user and the engine replace the
/// community:
///
///   moon run e2e/notify --target js -- send-v3  
///   moon run e2e/notify --target js -- recv-v3  
///
/// Exits non-zero if any check fails.

///|
const DEFAULT_SEND_HOST : String = "127.0.0.1"

///|
/// The port snmptrapd is published on in the documented docker run.
const DEFAULT_SEND_PORT : Int = 16162

///|
/// The port to listen on in receive mode; the standard trap port is
/// 162, and 16162 is its published equivalent.
const DEFAULT_RECV_PORT : Int = 16162

///|
/// How many notifications receive mode waits for.
const DEFAULT_WANTED : Int = 3

///|
/// How long to wait for each notification, in milliseconds.
const WAIT_MS : Int = 10000

///|
/// The community every notification in this harness uses.
const COMMUNITY : Bytes = b"public"

///|
/// The v3 user, and the passwords the fixture in `e2e/docker` provisions.
const V3_USER : Bytes = b"e2e-trap"

///|
const V3_AUTH_PASSWORD : Bytes = b"trappass"

///|
const V3_PRIV_PASSWORD : Bytes = b"trapprivpass"

///|
/// The engine this harness claims when it *sends* a trap. A trap is
/// authoritative for the sender's engine, so the receiver has to know
/// this id in advance — `snmptrapd.conf` carries the matching
/// `createUser -e` line.
const V3_SENDER_ENGINE_ID : Bytes = b"\x80\x00\x1f\x88\x04moon-snmp-trap"

///|
/// The engine this harness claims when it *receives* an inform. The
/// sender learns it by discovery, so nothing is configured with it in
/// advance.
const V3_RECEIVER_ENGINE_ID : Bytes = b"\x80\x00\x1f\x88\x04moon-snmp-rcv"

///|
/// The v3 user this harness authenticates as.
fn v3_security() -> @snmpv3.Security {
  {
    level: @snmpv3.AuthPriv,
    user: V3_USER,
    auth_protocol: @snmpv3.Sha1,
    auth_password: V3_AUTH_PASSWORD,
    priv_protocol: @snmpv3.Aes128,
    priv_password: V3_PRIV_PASSWORD,
  }
}

///|
/// Our identity as a trap sender: a fixed engine id with a clock that
/// does not move backwards.
fn v3_sender_engine() -> @snmpv3.Engine {
  { id: V3_SENDER_ENGINE_ID, boots: 1, time: 1, }
}

///|
/// Our identity as an inform receiver.
fn v3_receiver_engine() -> @snmpv3.Engine {
  { id: V3_RECEIVER_ENGINE_ID, boots: 1, time: 1, }
}

///|
/// fn main dispatch. The mode comes first so an unknown one fails fast
/// rather than silently doing nothing.
fn main {
  let args = @support.Args::read()
  let mode = args.text(2, "")
  let checks = @support.Checks::new()
  match mode {
    "send" => {
      let host = args.text(3, DEFAULT_SEND_HOST)
      let port = args.number(4, DEFAULT_SEND_PORT)
      println("notify send suite against \{host}:\{port}\n")
      send_suite(checks, host, port) catch {
        e => checks.bad("the suite ran to completion", Repr(e).to_string())
      }
      checks.finish("notify send suite")
    }
    "loopback" => {
      println(
        "notify loopback suite: five notifications, one socket to another\n",
      )
      loopback_suite(checks) catch {
        e => checks.bad("the suite ran to completion", Repr(e).to_string())
      }
      checks.finish("notify loopback suite")
    }
    "recv" => {
      let port = args.number(3, DEFAULT_RECV_PORT)
      let wanted = args.number(4, DEFAULT_WANTED)
      println("notify receive suite on udp/\{port}, expecting \{wanted}\n")
      recv_suite(checks, port, wanted) catch {
        e => checks.bad("the suite ran to completion", Repr(e).to_string())
      }
      checks.finish("notify receive suite")
    }
    "send-v3" => {
      let host = args.text(3, DEFAULT_SEND_HOST)
      let port = args.number(4, DEFAULT_SEND_PORT)
      println("notify v3 send suite against \{host}:\{port}\n")
      send_v3_suite(checks, host, port) catch {
        e => checks.bad("the suite ran to completion", Repr(e).to_string())
      }
      checks.finish("notify v3 send suite")
    }
    "recv-v3" => {
      let port = args.number(3, DEFAULT_RECV_PORT)
      let wanted = args.number(4, DEFAULT_WANTED)
      // A deliberate refusal (a wrong password, say) is counted, not
      // treated as an accident: the caller says how many to expect.
      let refused = args.number(5, 0)
      println("notify v3 receive suite on udp/\{port}, expecting \{wanted}\n")
      recv_v3_suite(checks, port, wanted, refused) catch {
        e => checks.bad("the suite ran to completion", Repr(e).to_string())
      }
      checks.finish("notify v3 receive suite")
    }
    _ => {
      println(
        "usage: e2e/notify [send   | recv   | send-v3   | recv-v3   | loopback]",
      )
      @support.exit_with(2)
    }
  }
}

///|
/// Send one of each notification shape to a real receiver.
///
/// The three traps can only be checked from the receiver's side, which
/// CI greps for. The inform is different: it is acknowledged, and an
/// acknowledgement is a receiver saying it understood the message, so
/// that check lives here.
fn send_suite(
  checks : @support.Checks,
  host : String,
  port : Int,
) -> Unit raise {
  let socket = @transport.Transport::open(0, 3000)
  println("sending from udp/\{socket.local_port()}")
  let traps = @session.Session::new(
    socket,
    host,
    port,
    @pdu.V2c,
    COMMUNITY,
    2000,
    1,
    100,
  )

  // A v2c trap naming a standard trap OID.
  traps.trap_v2(123456, @mib.link_down(), [
    @pdu.VarBind::new(@mib.if_descr(), @ber.BerValue::OctetString(b"eth0")),
  ])
  checks.ok("trap_v2 with a standard trap OID sent")

  // A v2c trap from a private enterprise, with values of two different
  // types, which is where an encoder bug would show.
  traps.trap_v2(124000, @oid.Oid::parse("1.3.6.1.4.1.99999.1.2.3"), [
    @pdu.VarBind::new(
      @oid.Oid::parse("1.3.6.1.4.1.99999.1.1.1"),
      @ber.BerValue::OctetString(b"vendor payload"),
    ),
    @pdu.VarBind::new(
      @oid.Oid::parse("1.3.6.1.4.1.99999.1.1.2"),
      @ber.BerValue::Integer(42),
    ),
  ])
  checks.ok("trap_v2 from a private enterprise sent")

  // An unsigned value whose top bit is set. It has to travel with a
  // leading zero octet to stay non-negative, and the receiver has to
  // read it back as the large number it is rather than as minus one —
  // which is a bug this harness caught on the way in.
  traps.trap_v2(125000, @mib.authentication_failure(), [
    @pdu.VarBind::new(
      @oid.Oid::parse("1.3.6.1.4.1.99999.1.3.1"),
      @ber.BerValue::Gauge32(0xFFFFFFFF),
    ),
    @pdu.VarBind::new(
      @oid.Oid::parse("1.3.6.1.4.1.99999.1.3.2"),
      @ber.BerValue::Counter32(0x80000000),
    ),
  ])
  checks.ok("trap_v2 with saturated unsigned values sent")

  // A v1 trap: a different body shape, and version 0 on the wire.
  traps.trap_v1(
    @oid.Oid::parse("1.3.6.1.4.1.8072"),
    b"\x7f\x00\x00\x01",
    6, // enterprise-specific
    1101,
    125000,
    [
      @pdu.VarBind::new(
        @oid.Oid::parse("1.3.6.1.4.1.8072.9999.1"),
        @ber.BerValue::OctetString(b"v1 payload"),
      ),
    ],
  )
  checks.ok("trap_v1 sent")

  // The inform: this is the one that proves a real receiver accepted
  // our message, because it only answers one it understood.
  let ack = traps.inform(126000, @mib.warm_start(), [
    @pdu.VarBind::new(@mib.sys_name(), @ber.BerValue::OctetString(b"moon-snmp")),
  ])
  checks.num("inform was acknowledged", ack.length(), 3)
  match ack.get(1) {
    Some(vb) =>
      match vb.value {
        @ber.BerValue::ObjectIdentifier(o) =>
          checks.text(
            "the acknowledgement echoes the trap OID",
            o.to_dotted(),
            @mib.warm_start().to_dotted(),
          )
        _ =>
          checks.bad(
            "the acknowledgement echoes the trap OID", "value was not an OID",
          )
      }
    None =>
      checks.bad("the acknowledgement echoes the trap OID", "no varbind 1")
  }

  // A second inform, so the request-id allocation across notifications
  // is exercised: the receiver must answer this one too.
  let ack2 = traps.inform(127000, @mib.cold_start(), [])
  checks.num("a second inform was acknowledged", ack2.length(), 2)

  socket.close()
}

///|
/// Listen for notifications and check each one.
///
/// Every notification is checked the same way regardless of who sent
/// it: it must be one of the three shapes, name what it is about, and
/// carry an uptime. An inform must also have been acknowledged — the
/// counter is how this side knows the reply went out.
fn recv_suite(checks : @support.Checks, port : Int, wanted : Int) -> Unit raise {
  let socket = @transport.Transport::open(port, 3000)
  println("listening on udp/\{socket.local_port()}")
  let receiver = @session.Receiver::new(socket, Some(COMMUNITY))

  let mut seen_v2c = 0
  let mut seen_v1 = 0
  let mut seen_inform = 0
  let mut got = 0
  while got < wanted {
    let notification = receiver.recv(WAIT_MS) catch {
      @session.SnmpError::NoResponse => {
        checks.bad(
          "all expected notifications arrived",
          "gave up after \{got} of \{wanted}",
        )
        break
      }
      e => {
        checks.bad("receive", Repr(e).to_string())
        break
      }
    }
    got = got + 1
    let label = "\{got}"
    println(
      "\n[\{got}] from \{notification.source_addr}:\{notification.source_port}",
    )
    for vb in notification.varbinds() {
      println("     \{vb.name.to_dotted()} = \{@mib.format_value(vb.value)}")
    }

    // Every notification names itself, whichever shape it has.
    let naming = notification.trap_oid()
    checks.that(
      "notification \{label} names what it is about",
      naming is Some(_),
    )
    match naming {
      Some(o) => println("     -> \{o.to_dotted()}")
      None => ()
    }
    // The origin has to be right, or an acknowledgement could not be
    // addressed.
    checks.that(
      "notification \{label} reports its origin",
      notification.source_addr != "",
    )
    // The uniform uptime accessor must work for all three shapes.
    checks.that(
      "notification \{label} has a readable uptime",
      notification.uptime() is Some(_),
    )

    match notification.kind {
      @session.TrapV2 => {
        seen_v2c = seen_v2c + 1
        checks.that("notification \{label} is a v2c trap", true)
      }
      @session.Inform => {
        seen_inform = seen_inform + 1
        checks.that("notification \{label} is an inform", true)
        // An inform that arrived but was not answered would leave the
        // sender retransmitting; the counter catches it here rather
        // than as a mystery failure in whatever sent it.
        checks.num(
          "inform \{label} was acknowledged",
          receiver.unacknowledged(),
          0,
        )
      }
      @session.TrapV1 => {
        seen_v1 = seen_v1 + 1
        // A v1 trap has no trap OID of its own; the accessor must have
        // translated the numeric code into one.
        checks.that(
          "notification \{label} is a v1 trap that translated to an OID",
          true,
        )
        match notification.trap_v1() {
          Some(t) =>
            checks.that(
              "v1 trap \{label} has a four-octet agent address",
              t.agent_addr.length() == 4,
            )
          None => checks.bad("v1 trap \{label} body", "missing")
        }
      }
    }
  }

  checks.num("all expected notifications arrived", got, wanted)
  checks.num("nothing was rejected", receiver.rejected(), 0)
  checks.num("nothing was left unacknowledged", receiver.unacknowledged(), 0)
  checks.that(
    "the run covered all three notification shapes",
    seen_v2c > 0 && seen_v1 > 0 && seen_inform > 0,
  )
  println(
    "\ncounts: v2c traps \{seen_v2c}, v1 traps \{seen_v1}, informs \{seen_inform}",
  )
  socket.close()
}

///|
/// Send v3 notifications to a real receiver.
///
/// The trap can only be checked from the receiver's side, so CI greps
/// snmptrapd's log for it. The inform is checked here: an
/// acknowledgement means the receiver verified our signature, decrypted
/// the message and understood it — the strongest thing this side can
/// observe.
fn send_v3_suite(
  checks : @support.Checks,
  host : String,
  port : Int,
) -> Unit raise {
  let socket = @transport.Transport::open(0, 3000)
  println("sending v3 from udp/\{socket.local_port()}")
  let client = @session.V3Client::new(
    socket,
    host,
    port,
    v3_security(),
    2000,
    1,
  )
  client.set_local_engine(v3_sender_engine())

  // A trap OID of our own, so the receiver's log line cannot be confused
  // with the v2c suite's linkDown.
  client.trap(131000, @oid.Oid::parse("1.3.6.1.4.1.99999.3.1"), [
    @pdu.VarBind::new(@mib.if_descr(), @ber.BerValue::OctetString(b"eth0")),
  ])
  checks.ok("a v3 authPriv trap sent")

  let ack = client.inform(132000, @mib.warm_start(), [
    @pdu.VarBind::new(@mib.sys_name(), @ber.BerValue::OctetString(b"moon-snmp")),
  ])
  checks.num("the v3 authPriv inform was acknowledged", ack.length(), 3)
  match ack.get(1) {
    Some(vb) =>
      match vb.value {
        @ber.BerValue::ObjectIdentifier(o) =>
          checks.text(
            "the acknowledgement echoes the trap OID",
            o.to_dotted(),
            @mib.warm_start().to_dotted(),
          )
        _ =>
          checks.bad(
            "the acknowledgement echoes the trap OID", "value was not an OID",
          )
      }
    None =>
      checks.bad("the acknowledgement echoes the trap OID", "no varbind 1")
  }
  socket.close()
}

///|
/// Listen for v3 notifications from a real sender.
///
/// A v3 trap is authoritative for the *sender's* engine, so nothing is
/// configured here about who will call: the receiver learns the engine
/// from the message and localizes the key for it. An inform is the
/// other way round — the sender discovers *our* engine first — which is
/// what `discoveries()` counts.
fn recv_v3_suite(
  checks : @support.Checks,
  port : Int,
  wanted : Int,
  refused : Int,
) -> Unit raise {
  let socket = @transport.Transport::open(port, 3000)
  println("listening on udp/\{socket.local_port()}")
  let receiver = @session.Receiver::v3(
    socket,
    v3_receiver_engine(),
    v3_security(),
  )
  let mut got = 0
  let mut traps = 0
  let mut informs = 0
  while got < wanted {
    let notification = receiver.recv(WAIT_MS) catch {
      @session.SnmpError::NoResponse => {
        checks.bad(
          "all expected v3 notifications arrived",
          "gave up after \{got} of \{wanted}",
        )
        break
      }
      e => {
        checks.bad("receive", Repr(e).to_string())
        break
      }
    }
    got = got + 1
    let label = "\{got}"
    println(
      "\n[\{got}] \{kind_name(notification.kind)} from \{notification.source_addr}:\{notification.source_port}",
    )
    for vb in notification.varbinds() {
      println("     \{vb.name.to_dotted()} = \{@mib.format_value(vb.value)}")
    }
    checks.that(
      "v3 notification \{label} arrived over v3",
      notification.version == @pdu.V3,
    )
    match notification.v3 {
      Some(info) => {
        checks.text(
          "v3 notification \{label} names the user it authenticated as",
          @utf8.decode_lossy(info.user),
          @utf8.decode_lossy(V3_USER),
        )
        checks.that(
          "v3 notification \{label} was authenticated and encrypted",
          info.level == @snmpv3.AuthPriv,
        )
        checks.that(
          "v3 notification \{label} names the engine it was scoped to",
          info.engine_id.length() > 0,
        )
        let context_owner = if info.context_engine_id == v3_receiver_engine().id {
          "this receiver"
        } else if info.context_engine_id == info.engine_id {
          "the sending engine"
        } else {
          "neither"
        }
        println(
          "     context engine: \{context_owner}, name \"\{@utf8.decode_lossy(info.context_name)}\"",
        )
      }
      None =>
        checks.bad("v3 notification \{label} carries a v3 context", "missing")
    }
    checks.that(
      "v3 notification \{label} names what it is about",
      notification.trap_oid() is Some(_),
    )
    checks.that(
      "v3 notification \{label} has a readable uptime",
      notification.uptime() is Some(_),
    )
    match notification.kind {
      @session.TrapV2 => {
        traps = traps + 1
        checks.that("v3 notification \{label} is a trap", true)
      }
      @session.Inform => {
        informs = informs + 1
        checks.that("v3 notification \{label} is an inform", true)
        checks.num(
          "v3 inform \{label} was acknowledged",
          receiver.unacknowledged(),
          0,
        )
      }
      @session.TrapV1 =>
        checks.bad(
          "v3 notification \{label} is a v1 trap",
          "v1 cannot travel in v3",
        )
    }
  }
  checks.num("all expected v3 notifications arrived", got, wanted)
  checks.num(
    "only the deliberate refusals were rejected",
    receiver.rejected(),
    refused,
  )
  checks.num("nothing was left unacknowledged", receiver.unacknowledged(), 0)
  checks.that(
    "the inform sender discovered our engine",
    receiver.discoveries() >= 1,
  )
  checks.that(
    "the run covered a v3 trap and a v3 inform",
    traps > 0 && informs > 0,
  )
  println("\ncounts: v3 traps \{traps}, v3 informs \{informs}")
  socket.close()
}

///|
/// A short name for a kind, so a sequence can be compared as text.
fn kind_name(kind : @session.NotificationKind) -> String {
  match kind {
    @session.TrapV1 => "trap-v1"
    @session.TrapV2 => "trap-v2"
    @session.Inform => "inform"
  }
}

///|
/// Send five notifications from one socket to another **in this
/// process**, with nothing between them, and check that all five arrive
/// in the order they were sent.
///
/// This is the regression check for two bugs in the transport bridge
/// that only a burst exposes:
///
///   * when a send travelled through the shared buffer, the second send
///     overwrote the first before the worker read it, so a datagram
///     arrived twice and another not at all;
///   * when the receive side held a single datagram, everything after
///     the first of a burst was dropped.
///
/// The shapes alternate so that a clobbered or coalesced send changes
/// the sequence, not merely the count. It needs no docker and no second
/// process, so it runs anywhere.
fn loopback_suite(checks : @support.Checks) -> Unit raise {
  let receiving_socket = @transport.Transport::open(0, 3000)
  let port = receiving_socket.local_port()
  println("receiver on udp/\{port}")
  let sending_socket = @transport.Transport::open(0, 3000)
  let sender = @session.Session::new(
    sending_socket,
    "127.0.0.1",
    port,
    @pdu.V2c,
    COMMUNITY,
    WAIT_MS,
    0,
    500,
  )
  let receiver = @session.Receiver::new(receiving_socket, Some(COMMUNITY))

  // Back to back, no sleeping: that is the whole point.
  sender.trap_v2(1, @mib.link_down(), [
    @pdu.VarBind::new(@mib.if_descr(), @ber.BerValue::OctetString(b"eth0")),
  ])
  sender.trap_v1(
    @oid.Oid::parse("1.3.6.1.4.1.8072"),
    b"\x7f\x00\x00\x01",
    2,
    0,
    10,
    [],
  )
  sender.trap_v2(2, @mib.link_up(), [])
  sender.trap_v1(
    @oid.Oid::parse("1.3.6.1.4.1.8072"),
    b"\x7f\x00\x00\x01",
    3,
    0,
    20,
    [],
  )
  sender.trap_v2(3, @mib.cold_start(), [])

  let wanted : Array[String] = [
    "trap-v2", "trap-v1", "trap-v2", "trap-v1", "trap-v2",
  ]
  let got : Array[String] = []
  for _ in 0..
      checks.text(
        "a datagram sent just before close still arrives",
        o.to_dotted(),
        @mib.link_up().to_dotted(),
      )
    None =>
      checks.bad(
        "a datagram sent just before close still arrives", "no trap OID",
      )
  }
  last_socket.close()

  // IPv6, the same way: two sockets and one datagram. A socket belongs to
  // one family, so this is the only thing that proves the other family
  // works — a v4 socket cannot reach `::1` and a v6 one cannot reach a
  // dotted-decimal address.
  ipv6_round_trip(checks)

  receiving_socket.close()
  sending_socket.close()
}

///|
/// One datagram across an IPv6 loopback.
///
/// A host without IPv6 is a skip, not a failure: the point is to prove
/// that when the platform has it, this library can use it. The reason is
/// printed either way, so a regression here is not mistaken for a host
/// without v6.
fn ipv6_round_trip(checks : @support.Checks) -> Unit {
  let receiving = @transport.Transport::open_ipv6(0, 3000) catch {
    e => {
      println("       (no IPv6 loopback here: \{Repr(e)} — skipped)")
      return
    }
  }
  let port = receiving.local_port()
  let sending = @transport.Transport::open_ipv6(0, 3000) catch {
    e => {
      checks.bad("a second IPv6 socket opens", Repr(e).to_string())
      receiving.close()
      return
    }
  }
  let outcome = try {
    sending.send("::1", port, b"v6")
    receiving.recv(2000)
  } catch {
    @transport.TransportError::Timeout => {
      checks.bad("a datagram crosses the IPv6 loopback", "timeout")
      sending.close()
      receiving.close()
      return
    }
    e => {
      checks.bad("a datagram crosses the IPv6 loopback", Repr(e).to_string())
      sending.close()
      receiving.close()
      return
    }
  }
  checks.text(
    "an IPv6 datagram carries its payload",
    @utf8.decode_lossy(outcome.data),
    "v6",
  )
  // The address is what a receiver prints as a notification's origin, so
  // it has to be the v6 text rather than an empty string.
  checks.text("the IPv6 sender's address is reported", outcome.from_addr, "::1")
  sending.close()
  receiving.close()
}