///|
/// End-to-end query suite: drives a real net-snmp agent through this
/// library and checks what comes back.
///
///   docker build -t moon-snmp-e2e e2e/docker
///   docker run -d --name snmpd -p 16161:161/udp moon-snmp-e2e
///   moon run e2e/query --target js -- 127.0.0.1 16161
///
/// Exits non-zero if any check fails, which is what CI reads. Host and
/// port default to the command above, so a bare run works.

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

///|
const DEFAULT_PORT : Int = 16161

///|
/// How long to wait for one reply, in milliseconds.
const TIMEOUT_MS : Int = 2000

///|
/// How many times to retransmit before giving up.
const RETRIES : Int = 1

///|
/// Where the good session's request ids start. Distinct from the bad
/// session's below, so a late reply cannot be matched to the wrong
/// session when both share one socket.
const GOOD_ID_BASE : Int = 42000

///|
/// Where the wrong-community session's ids start.
const BAD_ID_BASE : Int = 9000

///|
/// How many times a character occurs in a string.
fn count_char(s : String, want : Char) -> Int {
  let mut n = 0
  for c in s {
    if c == want {
      n = n + 1
    }
  }
  n
}

///|
/// Whether an OID's text is inside the walk's subtree. A text prefix is
/// good enough here because the harness only uses it on names an agent
/// produced, where the arcs are already canonical.
fn under(name : @oid.Oid, root : @oid.Oid) -> Bool {
  root.is_prefix_of(name)
}

///|
/// The list of OIDs of a walk, for comparing two walks.
fn walk_names(entries : Array[@session.WalkEntry]) -> Array[String] {
  let out : Array[String] = []
  for e in entries {
    out.push(e.name.to_dotted())
  }
  out
}

///|
fn main {
  let args = @support.Args::read()
  let host = args.text(2, DEFAULT_HOST)
  let port = args.number(3, DEFAULT_PORT)
  println("query suite against \{host}:\{port}\n")
  let checks = @support.Checks::new()
  run_suite(checks, host, port) catch {
    e => checks.bad("the suite ran to completion", Repr(e).to_string())
  }
  checks.finish("query suite")
}

///|
/// The checks themselves.
///
/// Anything that raises — an agent that is not there, a timeout — ends
/// the suite early; `main` records that as the failure it is, so CI
/// shows which check the agent died on rather than a stack trace.
fn run_suite(checks : @support.Checks, host : String, port : Int) -> Unit raise {
  // One socket shared by both sessions, which is how a real program
  // uses this: a client is a conversation, not a socket.
  let socket = @support.open_endpoint(host)
  let session = @session.Session::new(
    socket,
    host,
    port,
    @pdu.V2c,
    b"public",
    TIMEOUT_MS,
    RETRIES,
    GOOD_ID_BASE,
  )

  // 1. GET: the liveness probe every SNMP tool starts with.
  let descr = session.get(@mib.sys_descr())
  let descr_text = match descr.value {
    @ber.BerValue::OctetString(b) => @utf8.decode_lossy(b)
    _ => ""
  }
  checks.that("get sysDescr.0 returns a value", descr_text.length() > 0)
  checks.text(
    "get answers under the OID it was asked for",
    descr.name.to_dotted(),
    @mib.sys_descr().to_dotted(),
  )
  println("       sysDescr.0 = \{descr_text}")

  // 2. GET with several OIDs: one round trip, answers in request order.
  let many = session.get_many([
    @mib.sys_up_time(),
    @mib.sys_name(),
    @mib.sys_contact(),
  ])
  checks.num("get_many returns one varbind per OID", many.length(), 3)
  checks.text(
    "get_many keeps the requested order",
    match many.get(1) {
      Some(vb) => vb.name.to_dotted()
      None => ""
    },
    @mib.sys_name().to_dotted(),
  )
  checks.text(
    "sysName.0 is the configured name",
    match many.get(1) {
      Some(vb) =>
        match vb.value {
          @ber.BerValue::OctetString(b) => @utf8.decode_lossy(b)
          _ => ""
        }
      None => ""
    },
    "moon-snmp-test-agent",
  )
  checks.text(
    "sysContact.0 is the configured contact",
    match many.get(2) {
      Some(vb) =>
        match vb.value {
          @ber.BerValue::OctetString(b) => @utf8.decode_lossy(b)
          _ => ""
        }
      None => ""
    },
    "dev@example.com",
  )

  // 3. sysUpTime.0 must be a TimeTicks, and the formatter must give it
  //    the shape a human reads.
  match many.get(0) {
    Some(vb) =>
      match vb.value {
        @ber.BerValue::TimeTicks(ticks) => {
          checks.that("sysUpTime.0 is a TimeTicks", true)
          let rendered = @mib.format_time_ticks(ticks)
          let shaped = count_char(rendered, ':') == 2 &&
            count_char(rendered, '.') == 1 &&
            rendered.length() >= 10
          checks.that("format_time_ticks gives H:MM:SS.hh", shaped)
          println("       sysUpTime.0 = \{rendered} (\{ticks} ticks)")
        }
        _ => checks.bad("sysUpTime.0 is a TimeTicks", "value was not TimeTicks")
      }
    None => checks.bad("sysUpTime.0 is a TimeTicks", "no varbind returned")
  }

  // 4. GETNEXT steps to the lexicographic successor.
  let next = session.get_next(@mib.sys_descr())
  checks.text(
    "get_next from sysDescr.0 lands on sysObjectID.0",
    next.name.to_dotted(),
    @mib.sys_object_id().to_dotted(),
  )

  // 5. GETBULK returns a run of successors in one round trip.
  let bulk = session.get_bulk([@mib.system()], 0, 10)
  checks.at_least("get_bulk returns a run of successors", bulk.length(), 8)
  let mut increasing = true
  let mut i = 1
  while i < bulk.length() {
    if bulk[i].name.compare(bulk[i - 1].name) <= 0 {
      increasing = false
    }
    i = i + 1
  }
  checks.that("get_bulk returns them in lexicographic order", increasing)

  // 6. A GET for something absent: v2c answers with the noSuchObject
  //    exception as a *value*, not with an error status (RFC 3416
  //    §4.2.1). Getting this wrong is what makes a missing OID look
  //    like a protocol failure.
  let missing_oid = @oid.Oid::parse("1.3.6.1.2.1.1.99.0")
  let missing = session.get(missing_oid)
  match missing.value {
    @ber.BerValue::Tagged(tag, _) =>
      checks.num("a missing OID comes back as noSuchObject", tag, 0x80)
    other =>
      checks.bad(
        "a missing OID comes back as noSuchObject",
        Repr(other).to_string(),
      )
  }

  // 7. GETNEXT from the last OID in the MIB reports endOfMibView rather
  //    than wrapping around.
  let past_end = session.get_next(@oid.Oid::parse("1.3.6.1.6.3.9999"))
  match past_end.value {
    @ber.BerValue::Tagged(tag, _) =>
      checks.num("stepping past the end reports endOfMibView", tag, 0x82)
    other =>
      checks.bad(
        "stepping past the end reports endOfMibView",
        Repr(other).to_string(),
      )
  }

  // 8. WALK the system group: every entry under the root, in order.
  let walked = session.walk(@mib.system(), 0)
  checks.at_least(
    "walk of the system group finds its scalars",
    walked.length(),
    8,
  )
  let mut all_inside = true
  let mut ascending = true
  let mut previous : @oid.Oid? = None
  for entry in walked {
    if !under(entry.name, @mib.system()) {
      all_inside = false
    }
    match previous {
      Some(p) => if entry.name.compare(p) <= 0 { ascending = false }
      None => ()
    }
    previous = Some(entry.name)
  }
  checks.that("every walked entry is inside the subtree", all_inside)
  checks.that("walk never repeats or goes backwards", ascending)
  checks.text(
    "walk starts at the group's first scalar",
    match walked.get(0) {
      Some(e) => e.name.to_dotted()
      None => ""
    },
    @mib.sys_descr().to_dotted(),
  )

  // 9. WALKBULK must agree with WALK entry for entry. Two independent
  //    implementations of the same traversal, checked against each
  //    other on real data.
  let bulk_walked = session.walk_bulk(@mib.system(), 10, 0)
  checks.num(
    "walk_bulk finds as many entries as walk",
    bulk_walked.length(),
    walked.length(),
  )
  checks.that(
    "walk_bulk returns the same OIDs in the same order",
    walk_names(bulk_walked) == walk_names(walked),
  )
  // A machine-readable line, so CI can compare our count with what
  // net-snmp's own snmpwalk finds for the same subtree. Kept separate
  // from the check output because it is read by a script, not a person.
  println("walk system: \{walked.length()} entries")

  // 10. Walking a scalar's own instance returns just that instance,
  //     which is what `snmpwalk ` prints.
  let scalar_walk = session.walk(@mib.sys_name(), 0)
  checks.num(
    "walk of a scalar instance returns one entry",
    scalar_walk.length(),
    1,
  )
  checks.text(
    "the scalar walk names the scalar it was given",
    match scalar_walk.get(0) {
      Some(e) => e.name.to_dotted()
      None => ""
    },
    @mib.sys_name().to_dotted(),
  )

  // 11. Walking a table column returns rows indexed by the table's
  //     index, so the names must all sit under the column.
  let column = session.walk(@mib.if_descr(), 0)
  checks.at_least(
    "walk of the ifDescr column finds interfaces",
    column.length(),
    1,
  )
  let mut column_inside = true
  for entry in column {
    if !under(entry.name, @mib.if_descr()) {
      column_inside = false
    }
  }
  checks.that("every column entry sits under the column OID", column_inside)
  for entry in column {
    println(
      "       \{entry.name.to_dotted()} = \{@mib.format_value(entry.value)}",
    )
  }

  // 12. Walking a subtree that does not exist yields nothing, and
  //     terminates rather than running to the end of the MIB.
  let empty_walk = session.walk(@oid.Oid::parse("1.3.6.1.2.1.1.99"), 0)
  checks.num("walking an empty subtree yields nothing", empty_walk.length(), 0)

  // 13. `max_steps` bounds the work.
  let bounded = session.walk(@mib.system(), 2)
  checks.num("walk honours max_steps", bounded.length(), 2)

  // 14. Consecutive requests must not reuse a request id (RFC 3416
  //     §4.1), or a slow reply could be matched to the wrong request.
  //     The harness sees the counter the session stamps into each
  //     request; that it reaches the wire is covered by `e2e/notify`,
  //     which reads the request id out of what a peer received.
  let id_first = session.take_request_id()
  let id_second = session.take_request_id()
  checks.that("request ids advance between requests", id_second > id_first)

  // 15. A wrong community is dropped in silence (RFC 1157 §4.1), so the
  //     only observable outcome is a timeout. A library that reported
  //     this as a decode error would be guessing.
  let bad = @session.Session::new(
    socket,
    host,
    port,
    @pdu.V2c,
    b"not-the-community",
    500,
    1,
    BAD_ID_BASE,
  )
  let mut timed_out = false
  try {
    let _ = bad.get(@mib.sys_descr())
  } catch {
    @session.SnmpError::NoResponse => timed_out = true
    _ => ()
  }
  checks.that("a wrong community times out instead of answering", timed_out)

  // 16. Liveness, as one call: the device's own clock says it is there,
  //     and the wrong community above is reported as absent rather than
  //     as an error — v1/v2c cannot tell those apart, so the API must
  //     not pretend to.
  let alive = session.probe()
  checks.that("probe finds the running agent", alive.reachable)
  checks.that("probe reports the agent's uptime", alive.uptime > 0)
  let gone = bad.probe()
  checks.that("probe reports a wrong community as unreachable", !gone.reachable)

  // 17. A v1 session still reads a v2c agent: version 1 requests are
  //     what an old device needs, and the agent here answers both.
  let v1_read = session.request_versioned(
    @pdu.V1,
    @pdu.Pdu::request(@pdu.GetRequest, 0, [
      @pdu.VarBind::new(@mib.sys_name(), @ber.BerValue::Null),
    ]),
  )
  checks.num("a v1 GET still returns an answer", v1_read.length(), 1)

  socket.close()
}