"""Native SNMP GETBULK subtree walk over the shared SnmpEngine transport.

bulkwalk(engine, host, community, root_oid) walks one OID subtree on one host
and returns a WalkResult with the accumulated (oid, value) rows, where ``oid``
is a tuple of integer sub-identifiers (SNMP's native form -- see SnmpEngine).
It rides on a SnmpEngine, so a caller runs as many concurrent walks (different
subtrees, different hosts) as it likes via asyncio.gather.

A walk is a sequential dependent chain -- each GETBULK starts from the last OID
the previous response returned -- so its correctness hinges on the error
handling documented on bulkwalk(): it always terminates (loop guards + absolute
caps) and never confuses a response-too-big-for-the-path condition with a dead
host (a GET liveness probe disambiguates).
"""
from collections import namedtuple

from preseem.snmp_wire import (SNMP_V2C, VERSION_LABEL, SnmpException, build_get,
                               build_getbulk, oid_in_subtree, oid_to_ints)

# sysUpTime.0: universally implemented, tiny reply -- the simplest GET we can
# use to decide whether a host is responsive at all.
LIVENESS_OID = '1.3.6.1.2.1.1.3.0'

# error-status value we special-case (RFC 3416).
_TOO_BIG = 1

# ``rows`` is a list of (oid, value): oid is a tuple of int sub-identifiers, and
# value is decoded per type (int for counters, bytes for OCTET STRING / Opaque,
# a dotted string for OID values -- see snmp_engine / decode_response).
WalkResult = namedtuple('WalkResult',
                        ('host', 'root', 'status', 'version', 'rows', 'requests'))


async def bulkwalk(engine,
                   host,
                   community,
                   root_oid,
                   *,
                   port=161,
                   timeout=5.0,
                   retries=2,
                   max_repetitions=10,
                   min_repetitions=1,
                   max_rows=100_000,
                   max_requests=5_000):
    """Walk the ``root_oid`` subtree on ``host`` and return a WalkResult.

    Never raises on a network/agent fault: every outcome is a terminal status
    carrying whatever rows were gathered. Statuses:
      ok                  reached the end of the subtree cleanly
      timeout             no reply, and a GET liveness probe also failed (dead)
      bulk-failed         host answers GET but not even a min-size GETBULK
      too-big             agent returned tooBig even at min_repetitions
      oid-not-increasing  agent repeated or reversed an OID (loop guard tripped)
      snmp-error          agent returned a non-zero error-status other than tooBig
      truncated           hit max_rows or max_requests

    Two invariants hold regardless of agent/network behaviour: (1) the walk
    always terminates, and (2) an MTU black hole -- a response too large for the
    path, silently dropped, which looks like a timeout rather than a tooBig --
    is distinguished from a dead host (via the liveness GET) and handled by
    reducing max-repetitions by one, not by declaring the host down.
    """
    if min_repetitions < 1:
        raise ValueError('min_repetitions must be >= 1')
    if max_repetitions < min_repetitions:
        raise ValueError('max_repetitions must be >= min_repetitions')

    reps = max_repetitions
    root_ints = oid_to_ints(root_oid)
    addr = (host, port)
    ver_label = VERSION_LABEL[SNMP_V2C]

    rows = []
    next_oid = root_oid
    last_ints = root_ints  # walk-global high-water mark (strictly increasing)
    requests = 0

    def result(status):
        return WalkResult(host, root_oid, status, ver_label, rows, requests)

    while True:
        if requests >= max_requests:
            return result('truncated')
        rid = engine.next_rid()
        pkt = build_getbulk(rid, community, [next_oid], 0, reps, SNMP_V2C)
        requests += 1
        resp = await engine.request(pkt, addr, rid, timeout=timeout, retries=retries)

        if resp is None:
            # No GETBULK reply: either the host is dead, or the response was too
            # large for the path and was silently dropped. Disambiguate with the
            # simplest possible GET before deciding.
            if requests >= max_requests:
                return result('truncated')
            rid2 = engine.next_rid()
            getpkt = build_get(rid2, community, [LIVENESS_OID], SNMP_V2C)
            requests += 1
            live = await engine.request(getpkt,
                                        addr,
                                        rid2,
                                        timeout=timeout,
                                        retries=retries)
            if live is None:
                return result('timeout')  # host/path genuinely unresponsive
            # Host is alive, so the GETBULK loss is response-size related.
            if reps > min_repetitions:
                reps -= 1  # reduce by one; keep repetitions as high as fits
                continue
            return result('bulk-failed')  # alive but min-size bulk still lost

        if resp.error_status == _TOO_BIG:
            # The agent itself says the response won't fit; it clearly answered,
            # so no liveness probe is needed -- just ask for fewer repetitions.
            if reps > min_repetitions:
                reps -= 1
                continue
            return result('too-big')
        if resp.error_status != 0:
            return result('snmp-error')

        if not resp.varbinds:
            return result('ok')  # empty response -> end of MIB

        last = None
        append = rows.append
        for vb in resp.varbinds:
            oid, value = vb  # oid is a tuple of int sub-identifiers
            if isinstance(value, SnmpException):
                # endOfMibView / noSuchObject / noSuchInstance -> subtree ended.
                return result('ok')
            if not oid_in_subtree(oid, root_ints):
                return result('ok')  # walked past the subtree root
            if not oid > last_ints:
                # Agent repeated or went backwards: advancing would loop.
                return result('oid-not-increasing')
            append(vb)  # reuse the tuple decode_response already built
            last_ints = oid
            last = oid
            if len(rows) >= max_rows:
                return result('truncated')

        if last is None:
            return result('ok')
        # Start the next GETBULK from the last OID. ber_oid wants a dotted
        # string, so stringify once per request -- not per row.
        next_oid = '.'.join(map(str, last))
