"""SNMP v1/v2c wire codec: BER encode/decode of the PDU shapes we use.

Hand-rolled BER for the handful of PDU shapes SNMP v1/v2c needs (GetRequest,
GetBulkRequest) plus response decoding and OID ordering helpers. Pure functions
only -- no sockets, no async, no state, no platform dependencies. (Socket
error-queue / ICMP decoding lives in the icmp_errqueue module.)
"""
# ---------------------------------------------------------------------------
# BER / ASN.1 encoding (only what SNMP v1/v2c needs)
# ---------------------------------------------------------------------------
# Tags we produce or consume.
T_INTEGER = 0x02
T_OCTETSTR = 0x04
T_NULL = 0x05
T_OID = 0x06
T_SEQUENCE = 0x30
T_GET_REQUEST = 0xA0
T_GET_RESPONSE = 0xA2
T_GET_BULK = 0xA5
# Application / context types seen in responses.
T_IPADDRESS = 0x40
T_COUNTER32 = 0x41
T_GAUGE32 = 0x42
T_TIMETICKS = 0x43
T_OPAQUE = 0x44
T_COUNTER64 = 0x46
# v2c per-varbind exception markers.
T_NOSUCHOBJECT = 0x80
T_NOSUCHINSTANCE = 0x81
T_ENDOFMIBVIEW = 0x82

# SNMP message version field values, and their output labels.
SNMP_V1 = 0
SNMP_V2C = 1
VERSION_LABEL = {SNMP_V1: '1', SNMP_V2C: '2c'}


class SnmpException(str):
    """A v2c per-varbind exception marker: endOfMibView, noSuchObject, or
    noSuchInstance.

    Subclasses str so a consumer that compares the decoded value against the
    label text keeps working, while code that must act on the marker (e.g. a
    walk deciding it has reached the end of a subtree) can detect it by type --
    an ordinary OCTET STRING whose text happens to equal one of these labels is
    a plain str, not an SnmpException.
    """
    __slots__ = ()


END_OF_MIB_VIEW = SnmpException('endOfMibView')
NO_SUCH_OBJECT = SnmpException('noSuchObject')
NO_SUCH_INSTANCE = SnmpException('noSuchInstance')


def ber_len(length):
    """Encode a BER length (short form, or long form for >= 128)."""
    if length < 0x80:
        return bytes((length, ))
    body = []
    while length:
        body.insert(0, length & 0xFF)
        length >>= 8
    return bytes((0x80 | len(body), )) + bytes(body)


def ber_tlv(tag, value):
    """Wrap a value in a tag + length header."""
    return bytes((tag, )) + ber_len(len(value)) + value


def ber_uint(n):
    """Encode a non-negative integer as a minimal signed BER INTEGER body.

    All integers we emit (version, request-id, error fields) are >= 0, so a
    leading 0x00 is added when the top bit would otherwise signal negative.
    """
    if n == 0:
        return b'\x00'
    body = bytearray()
    while n:
        body.insert(0, n & 0xFF)
        n >>= 8
    if body[0] & 0x80:
        body.insert(0, 0x00)
    return bytes(body)


def ber_oid(oid):
    """Encode a dotted OID string to BER OID body bytes."""
    parts = [int(p) for p in oid.split('.')]
    if len(parts) < 2:
        raise ValueError('OID too short: %r' % oid)
    subids = [40 * parts[0] + parts[1]] + parts[2:]
    out = bytearray()
    for sub in subids:
        if sub < 0x80:
            out.append(sub)
            continue
        stack = [sub & 0x7F]
        sub >>= 7
        while sub:
            stack.append((sub & 0x7F) | 0x80)
            sub >>= 7
        out.extend(reversed(stack))
    return bytes(out)


def encode_varbinds(oids):
    """Encode the NULL-valued variable-bindings SEQUENCE for a GET/GETBULK.

    Depends only on the OID set, so it can be built once and reused across
    every packet that shares those OIDs.
    """
    vbs = bytearray()
    for oid in oids:
        vb = ber_tlv(T_OID, ber_oid(oid)) + ber_tlv(T_NULL, b'')
        vbs += ber_tlv(T_SEQUENCE, vb)
    return ber_tlv(T_SEQUENCE, bytes(vbs))


def assemble_get(request_id, community, varbinds, version=SNMP_V2C):
    """Wrap a prebuilt variable-bindings SEQUENCE into a full GetRequest.

    ``varbinds`` is the output of encode_varbinds. Only request-id, community
    and version vary between packets, so passing a cached varbinds blob avoids
    re-encoding the OIDs each time. The GetRequest PDU is identical on the wire
    for v1 and v2c; only the version field differs (0 for v1, 1 for v2c).
    """
    pdu = (
        ber_tlv(T_INTEGER, ber_uint(request_id)) +
        ber_tlv(T_INTEGER, ber_uint(0)) +  # error-status
        ber_tlv(T_INTEGER, ber_uint(0)) +  # error-index
        varbinds)
    msg = (ber_tlv(T_INTEGER, ber_uint(version)) +
           ber_tlv(T_OCTETSTR, community.encode('latin-1')) +
           ber_tlv(T_GET_REQUEST, pdu))
    return ber_tlv(T_SEQUENCE, msg)


def build_get(request_id, community, oids, version=SNMP_V2C):
    """Build an SNMP GetRequest carrying a NULL-valued varbind per OID."""
    return assemble_get(request_id, community, encode_varbinds(oids), version)


def build_getbulk(request_id,
                  community,
                  oids,
                  non_repeaters=0,
                  max_repetitions=10,
                  version=SNMP_V2C):
    """Build a GetBulkRequest carrying a NULL-valued varbind per start OID.

    GetBulkRequest (v2c only) reuses the GetRequest PDU layout but reinterprets
    the two INTEGER fields after request-id as non-repeaters and max-repetitions
    (rather than error-status / error-index), under PDU tag T_GET_BULK. The
    reply is an ordinary GetResponse (T_GET_RESPONSE), so decode_response reads
    it unchanged. GETBULK does not exist in SNMPv1. (No assemble/build split like
    GET: a walk's start OID changes every request, so there is no varbinds blob
    worth caching.)
    """
    pdu = (ber_tlv(T_INTEGER, ber_uint(request_id)) +
           ber_tlv(T_INTEGER, ber_uint(non_repeaters)) +
           ber_tlv(T_INTEGER, ber_uint(max_repetitions)) + encode_varbinds(oids))
    msg = (ber_tlv(T_INTEGER, ber_uint(version)) +
           ber_tlv(T_OCTETSTR, community.encode('latin-1')) + ber_tlv(T_GET_BULK, pdu))
    return ber_tlv(T_SEQUENCE, msg)


# ---------------------------------------------------------------------------
# BER decoding
# ---------------------------------------------------------------------------
# Decoding walks the buffer by TLV span -- (tag, body_start, body_end) indices,
# not byte-slice copies -- so parsing a response allocates only the OID strings
# and scalar values it returns, no per-TLV intermediates. That keeps the receive
# hot path cheap even for a GETBULK reply carrying hundreds of varbinds.
def _read_span(d, i):
    """Return (tag, body_start, body_end) for the TLV at ``i``, without copying.

    ``d`` indexes to ints and slices to a buffer (bytes or memoryview). SNMP is
    definite-length only, so the next TLV begins at the returned body_end.
    """
    tag = d[i]
    b = d[i + 1]
    if b < 0x80:  # short-form length
        start = i + 2
        return tag, start, start + b
    n = b & 0x7F  # long-form: n length octets follow
    length = int.from_bytes(d[i + 2:i + 2 + n], 'big')
    start = i + 2 + n
    return tag, start, start + length


def _read_tlv(data, i):
    """Return (tag, value_bytes, next_index), the value copied out as a slice.

    A convenience form for building and inspecting packets; response decoding
    uses the copy-free _read_span directly.
    """
    tag, start, end = _read_span(data, i)
    return tag, data[start:end], end


def _decode_oid_ints_span(d, start, end):
    # Decode each sub-identifier as a base-128 varint (the first one may be
    # multi-byte too), then split the first back into the two leading arcs:
    # arc1 is capped at 2, so first = 40*arc1 + arc2 only for first < 80;
    # beyond that arc1 == 2 and arc2 == first - 80. Returns the numeric sub-id
    # tuple -- SNMP's native OID form, which sorts and compares directly.
    #
    # This runs once per varbind and tops the walk's CPU profile, so the common
    # case is kept cheap: a sub-identifier < 128 is a single byte (val == 0),
    # emitted directly; only a continuation byte (top bit set) accumulates.
    subids = []
    append = subids.append
    val = 0
    for b in d[start:end]:
        if b < 0x80:
            append(((val << 7) | b) if val else b)
            val = 0
        else:
            val = (val << 7) | (b & 0x7F)
    if not subids:
        return ()
    first = subids[0]
    if first < 80:
        subids[0:1] = (first // 40, first % 40)
    else:
        subids[0:1] = (2, first - 80)
    return tuple(subids)


def _decode_oid_span(d, start, end):
    return '.'.join(map(str, _decode_oid_ints_span(d, start, end)))


def _decode_value_span(tag, d, start, end):
    if tag == T_OCTETSTR or tag == T_OPAQUE:
        # OCTET STRING and Opaque are byte strings; return them raw. Decoding
        # text (or formatting a MAC, etc.) is the caller's choice -- an OCTET
        # STRING in an arbitrary subtree is as often binary (MAC addresses,
        # hex-STRINGs) as display text.
        return bytes(d[start:end])
    if tag in (T_INTEGER, T_COUNTER32, T_GAUGE32, T_TIMETICKS, T_COUNTER64):
        return int.from_bytes(d[start:end], 'big', signed=(tag == T_INTEGER))
    if tag == T_OID:
        return _decode_oid_span(d, start, end)
    if tag == T_IPADDRESS:
        return '.'.join(str(b) for b in d[start:end])
    if tag == T_NULL:
        return None
    if tag == T_NOSUCHOBJECT:
        return NO_SUCH_OBJECT
    if tag == T_NOSUCHINSTANCE:
        return NO_SUCH_INSTANCE
    if tag == T_ENDOFMIBVIEW:
        return END_OF_MIB_VIEW
    return d[start:end].hex()


def _decode_oid(body):
    """Decode a BER OID body (bytes-like) to a dotted string."""
    return _decode_oid_span(body, 0, len(body))


def _decode_value(tag, body):
    """Decode a BER value body (bytes-like) for the given tag."""
    return _decode_value_span(tag, body, 0, len(body))


class SnmpResponse:
    """A decoded SNMP response: version, request_id, error_status, and a list of
    (oid, value) varbinds.

    A __slots__ class rather than a namedtuple: it is built once per reply on
    the receive hot path, where namedtuple's Python-level __new__ and descriptor
    attribute access measurably cost more than plain slots.
    """
    __slots__ = ('version', 'request_id', 'error_status', 'varbinds')

    def __init__(self, version, request_id, error_status, varbinds):
        self.version = version
        self.request_id = request_id
        self.error_status = error_status
        self.varbinds = varbinds

    def __repr__(self):
        return ('SnmpResponse(version=%r, request_id=%r, error_status=%r, '
                'varbinds=[%d])' %
                (self.version, self.request_id, self.error_status, len(self.varbinds)))


def decode_response(data, oid_as_ints=False):
    """Decode an SNMP v1/v2c Response message.

    Returns an SnmpResponse, or None if this is not a well-formed response PDU.
    ``version`` is the message's version field (0=v1, 1=v2c) taken from the
    reply itself. varbinds is a list of (oid, value) tuples.

    ``oid`` is a dotted string by default, or a tuple of integer sub-identifiers
    when ``oid_as_ints`` is set -- SNMP's native OID, which a walk can order and
    slice without re-parsing a string the decoder would otherwise build and
    throw away. OCTET STRING / Opaque values are returned as raw bytes; decoding
    text (or formatting a MAC, etc.) is the caller's choice.

    Parsing is copy-free: ``data`` (bytes, or a memoryview over a reused receive
    buffer) is walked by TLV span, so no per-TLV slices are allocated -- only
    the decoded OIDs / scalar values that must outlive the buffer. Those are
    fully materialised, so a caller may overwrite the buffer as soon as this
    returns.
    """
    d = data if type(data) is memoryview else memoryview(data)
    tag, s, _e = _read_span(d, 0)
    if tag != T_SEQUENCE:
        return None
    _, vs, ve = _read_span(d, s)  # version
    _, _cs, ce = _read_span(d, ve)  # community (skipped)
    ptag, ps, _pe = _read_span(d, ce)
    if ptag != T_GET_RESPONSE:
        return None
    _, rs, re_ = _read_span(d, ps)  # request-id
    _, ess, ese = _read_span(d, re_)  # error-status
    _, _eis, eie = _read_span(d, ese)  # error-index (skipped)
    _, vls, vle = _read_span(d, eie)  # varbind list
    oid_decode = _decode_oid_ints_span if oid_as_ints else _decode_oid_span
    varbinds = []
    append = varbinds.append
    k = vls
    while k < vle:
        _, vbs, vbe = _read_span(d, k)
        k = vbe
        _, os_, oe = _read_span(d, vbs)  # varbind OID
        vtag, vvs, vve = _read_span(d, oe)  # varbind value
        oid = oid_decode(d, os_, oe)
        value = _decode_value_span(vtag, d, vvs, vve)
        append((oid, value))
    return SnmpResponse(int.from_bytes(d[vs:ve], 'big'),
                        int.from_bytes(d[rs:re_], 'big'),
                        int.from_bytes(d[ess:ese], 'big'), varbinds)


# ---------------------------------------------------------------------------
# OID ordering helpers (for GETNEXT/GETBULK subtree walks)
# ---------------------------------------------------------------------------
def oid_to_ints(oid):
    """Dotted OID string -> tuple of int sub-identifiers.

    Tuple comparison then yields SNMP lexicographic order (compared
    numerically, component by component). Comparing the dotted strings would be
    wrong: '...2.2.1.10' would sort before '...2.2.1.2'.
    """
    if not oid:
        return ()
    return tuple(int(p) for p in oid.split('.'))


def oid_in_subtree(oid_ints, root_ints):
    """True if oid_ints equals root_ints or is a descendant of it."""
    return oid_ints[:len(root_ints)] == root_ints


__all__ = [
    # tags
    'T_INTEGER',
    'T_OCTETSTR',
    'T_NULL',
    'T_OID',
    'T_SEQUENCE',
    'T_GET_REQUEST',
    'T_GET_RESPONSE',
    'T_GET_BULK',
    'T_IPADDRESS',
    'T_COUNTER32',
    'T_GAUGE32',
    'T_TIMETICKS',
    'T_OPAQUE',
    'T_COUNTER64',
    'T_NOSUCHOBJECT',
    'T_NOSUCHINSTANCE',
    'T_ENDOFMIBVIEW',
    # versions
    'SNMP_V1',
    'SNMP_V2C',
    'VERSION_LABEL',
    # exception markers
    'SnmpException',
    'END_OF_MIB_VIEW',
    'NO_SUCH_OBJECT',
    'NO_SUCH_INSTANCE',
    # encoding
    'ber_len',
    'ber_tlv',
    'ber_uint',
    'ber_oid',
    'encode_varbinds',
    'assemble_get',
    'build_get',
    'build_getbulk',
    # decoding
    'SnmpResponse',
    'decode_response',
    # oid helpers
    'oid_to_ints',
    'oid_in_subtree',
]
