"""SNMP v1/v2c probe engine: BER encode/decode, UDP transport, ICMP handling.

A single unconnected UDP socket fires an SNMP GET at each target and a receiver
callback matches replies by request-id + source address. There is no per-host
engine, socket, or thread, and the in-flight request window is bounded
(max_inflight), so the scan's working set stays flat regardless of the address
space -- though the responders/unreachable maps below do grow with the number
of hosts that answer or are terminally classified.
scan() consumes any iterable of Target and paces sends to a packets-per-second
rate; each result is delivered as a plain dict to an on_result(record) callback,
which decides what to do with it (serialize, store, forward, ...). The callback
runs on the event loop thread, so it should not block. Every result is also
retained on the Prober (responders / unreachable), so a caller that doesn't need
streaming can pass a no-op callback and read those after the scan.

When ProbeConfig.icmp is set (Linux only, no root), the socket also collects
ICMP errors via IP_RECVERR / the socket error queue to classify non-responders
(e.g. port-closed). An optional label_for(ip) callback tags those errors with a
group name, since the ICMP error carries only the IP, not the original target.

The BER encode/decode is hand-rolled (ber_* / decode_response /
parse_icmp_error) because only one PDU shape is ever built -- a GET of a fixed
set of scalar OIDs -- so there are no external dependencies.

Entry points:
    Prober(config, on_result, label_for=None)
    await run(config, on_result, targets, label_for=None) -> (Prober, seconds)
"""
import asyncio
import errno
import socket
import struct
import sys
import time
import traceback
from collections import namedtuple
from dataclasses import dataclass
from typing import Optional

# ---------------------------------------------------------------------------
# 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
# 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'}


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.

    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)


# ---------------------------------------------------------------------------
# BER decoding
# ---------------------------------------------------------------------------
def _read_len(data, i):
    b = data[i]
    i += 1
    if b < 0x80:
        return b, i
    n = b & 0x7F
    return int.from_bytes(data[i:i + n], 'big'), i + n


def _read_tlv(data, i):
    """Return (tag, value_bytes, next_index)."""
    tag = data[i]
    length, i = _read_len(data, i + 1)
    return tag, data[i:i + length], i + length


def _decode_oid(body):
    # Decode every 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.
    subids = []
    val = 0
    for b in body:
        val = (val << 7) | (b & 0x7F)
        if not b & 0x80:
            subids.append(val)
            val = 0
    if not subids:
        return ''
    first = subids[0]
    head = [first // 40, first % 40] if first < 80 else [2, first - 80]
    return '.'.join(str(s) for s in head + subids[1:])


def _decode_value(tag, body):
    if tag == T_OCTETSTR or tag == T_OPAQUE:
        try:
            return body.decode('utf-8')
        except UnicodeDecodeError:
            return body.hex()
    if tag in (T_INTEGER, T_COUNTER32, T_GAUGE32, T_TIMETICKS, T_COUNTER64):
        return int.from_bytes(body, 'big', signed=(tag == T_INTEGER))
    if tag == T_OID:
        return _decode_oid(body)
    if tag == T_IPADDRESS:
        return '.'.join(str(b) for b in body)
    if tag == T_NULL:
        return None
    if tag == T_NOSUCHOBJECT:
        return 'noSuchObject'
    if tag == T_NOSUCHINSTANCE:
        return 'noSuchInstance'
    if tag == T_ENDOFMIBVIEW:
        return 'endOfMibView'
    return body.hex()


SnmpResponse = namedtuple('SnmpResponse',
                          ('version', 'request_id', 'error_status', 'varbinds'))


def decode_response(data):
    """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, so it reflects which version actually answered -- not
    what we last sent. varbinds is a list of (oid_string, value) tuples.
    """
    tag, msg, _ = _read_tlv(data, 0)
    if tag != T_SEQUENCE:
        return None
    i = 0
    _, ver_body, i = _read_tlv(msg, i)  # version
    _, _community, i = _read_tlv(msg, i)  # community
    ptag, pdu, i = _read_tlv(msg, i)
    if ptag != T_GET_RESPONSE:
        return None
    j = 0
    _, rid, j = _read_tlv(pdu, j)
    _, es, j = _read_tlv(pdu, j)
    _, _ei, j = _read_tlv(pdu, j)  # error-index
    _, vblist, j = _read_tlv(pdu, j)
    varbinds = []
    k = 0
    while k < len(vblist):
        _, vb, k = _read_tlv(vblist, k)
        m = 0
        _, oid_body, m = _read_tlv(vb, m)
        vtag, val_body, m = _read_tlv(vb, m)
        varbinds.append((_decode_oid(oid_body), _decode_value(vtag, val_body)))
    return SnmpResponse(int.from_bytes(ver_body, 'big'), int.from_bytes(rid, 'big'),
                        int.from_bytes(es, 'big'), varbinds)


# ---------------------------------------------------------------------------
# ICMP error decoding (Linux IP_RECVERR / socket error queue)
# ---------------------------------------------------------------------------
IP_RECVERR = getattr(socket, 'IP_RECVERR', 11)
MSG_ERRQUEUE = getattr(socket, 'MSG_ERRQUEUE', 0x2000)
SO_EE_ORIGIN_LOCAL = 1
SO_EE_ORIGIN_ICMP = 2
# ICMP type 3 (destination unreachable) codes -> status label.
_ICMP_UNREACH = {
    0: 'net-unreachable',
    1: 'host-unreachable',
    2: 'proto-unreachable',
    3: 'port-closed',  # ICMP_PORT_UNREACH: host up, no SNMP service
    9: 'admin-prohibited',
    10: 'admin-prohibited',
    13: 'admin-prohibited',  # ICMP_PKT_FILTERED: firewall reject
}
# Any of these means "not an SNMP responder; stop probing this host".
CLOSED_STATUSES = frozenset(_ICMP_UNREACH.values()) | {'local-error'}


def classify_icmp(ee_origin, ee_type, ee_code, ee_errno):
    """Map a sock_extended_err into a short status label."""
    if ee_origin == SO_EE_ORIGIN_ICMP:
        if ee_type == 3:  # ICMP destination unreachable
            return _ICMP_UNREACH.get(ee_code, 'unreachable')
        if ee_type == 11:  # time exceeded
            return 'ttl-exceeded'
        return 'icmp-type-%d' % ee_type
    if ee_origin == SO_EE_ORIGIN_LOCAL:
        return {
            errno.EHOSTUNREACH: 'host-unreachable',
            errno.ENETUNREACH: 'net-unreachable'
        }.get(ee_errno, 'local-error')
    return 'error'


def parse_icmp_error(ancdata, address):
    """Parse an error-queue recvmsg result into (target_ip, status).

    ``address`` is the recvmsg msg_name (the destination of the datagram that
    triggered the error) and is the preferred way to attribute the error; the
    SO_EE_OFFENDER address embedded after the sock_extended_err is a fallback.
    Returns (None, None) if no IP_RECVERR ancillary data is present.
    """
    ip = address[0] if address and address[0] else None
    status = None
    for level, ctype, cdata in ancdata:
        if level != socket.IPPROTO_IP or ctype != IP_RECVERR or len(cdata) < 16:
            continue
        ee_errno, ee_origin, ee_type, ee_code = struct.unpack('=IBBB', cdata[:7])
        status = classify_icmp(ee_origin, ee_type, ee_code, ee_errno)
        if ip is None and len(cdata) >= 24:
            # Fall back to the SO_EE_OFFENDER sockaddr_in that follows the
            # 16-byte sock_extended_err: family(2) port(2) addr(4).
            if struct.unpack('=H', cdata[16:18])[0] == socket.AF_INET:
                ip = '.'.join(str(b) for b in cdata[20:24])
    return ip, status


# ---------------------------------------------------------------------------
# OIDs we collect (RFC1213 system group scalars)
# ---------------------------------------------------------------------------
SYS_OIDS = [
    ('sysDescr', '1.3.6.1.2.1.1.1.0'),
    ('sysObjectID', '1.3.6.1.2.1.1.2.0'),
    ('sysUpTime', '1.3.6.1.2.1.1.3.0'),
    ('sysName', '1.3.6.1.2.1.1.5.0'),
]
OID_TO_FIELD = {oid: name for name, oid in SYS_OIDS}
SCAN_OIDS = [oid for _, oid in SYS_OIDS]
# The scan OID set is fixed, so its variable-bindings are identical in every
# probe -- encode them once and reuse (see assemble_get in the send path).
_SCAN_VARBINDS = encode_varbinds(SCAN_OIDS)

# A probe target. ``subnet`` is a free-form grouping label echoed unchanged
# into the result record. A host's targets should be produced consecutively so
# the "stop after the first community that works" short-circuit applies.
Target = namedtuple('Target', ('subnet', 'ip', 'community'))
# A request we have sent and are waiting on. ``tries`` is the number of times
# it has been sent so far; the packet is rebuilt per send because the version
# can change between attempts (v2c early, v1 on the final try).
Pending = namedtuple('Pending', ('target', 'deadline', 'tries'))


@dataclass
class ProbeConfig:
    """Transport/protocol knobs for the probe engine.

    Typically only a couple are overridden, e.g. ProbeConfig(rate=500, retries=2).
    """
    port: int = 161
    timeout: float = 2.0
    retries: int = 1
    rate: float = 2000.0
    burst: int = 100
    max_inflight: int = 2500
    icmp: bool = False
    emit_icmp: Optional[bool] = None  # None -> follow icmp
    primary_version: int = SNMP_V2C
    fallback_version: Optional[int] = None  # v1-fallback piggyback if set

    def __post_init__(self):
        if self.emit_icmp is None:
            self.emit_icmp = self.icmp
        if self.rate <= 0 or self.burst <= 0:
            # scan() computes interval = burst / rate; reject up front rather
            # than raising ZeroDivisionError mid-scan.
            raise ValueError('rate and burst must be positive')
        if self.fallback_version is not None and self.retries < 1:
            raise ValueError(
                'fallback_version requires retries >= 1 so the primary version '
                'gets a clean first attempt before the final-retry fallback')


# ---------------------------------------------------------------------------
# Prober -- the probe engine
# ---------------------------------------------------------------------------
class Prober:

    def __init__(self, config, on_result, label_for=None):
        self.config = config
        self.on_result = on_result  # callback invoked once per result dict
        self.label_for = label_for  # optional callable(ip) -> group label
        self.icmp = config.icmp
        self.emit_icmp = config.emit_icmp
        self.icmp_enabled = False
        # Version strategy: probe primary_version on every attempt except, when
        # fallback_version is set, the final one (the v1-fallback / retry
        # piggyback). None fallback => single-version run.
        self.primary_version = config.primary_version
        self.fallback_version = config.fallback_version
        self.sock = None
        self.pending = {}  # request_id -> Pending
        self.responders = {}  # ip -> result dict (dedupe by host)
        self.unreachable = {}  # ip -> status (from ICMP)
        self._rid = 0
        self.sent = 0
        self.retransmits = 0
        self.send_errors = 0
        self.late_replies = 0  # replies matched after their deadline
        self.callback_errors = 0  # exceptions raised by on_result

    # -- socket lifecycle ---------------------------------------------------
    def open(self, loop):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setblocking(False)
        # Large buffers cut send/receive drops when traffic arrives in bursts.
        for opt in (socket.SO_RCVBUF, socket.SO_SNDBUF):
            try:
                self.sock.setsockopt(socket.SOL_SOCKET, opt, 4 * 1024 * 1024)
            except OSError:
                pass
        if self.icmp:
            try:
                self.sock.setsockopt(socket.IPPROTO_IP, IP_RECVERR, 1)
                self.icmp_enabled = True
            except OSError as e:
                print('warning: ICMP error reporting unavailable (%s); '
                      'continuing without it' % e,
                      file=sys.stderr)
        self.sock.bind(('0.0.0.0', 0))
        loop.add_reader(self.sock.fileno(), self._on_readable)

    def close(self, loop):
        if self.sock is None:  # open() failed before creating the socket
            return
        try:
            loop.remove_reader(self.sock.fileno())
        except (OSError, ValueError):
            pass
        self.sock.close()

    # -- readable callback --------------------------------------------------
    def _on_readable(self):
        # Drain the error queue first so ICMP verdicts land before we would
        # otherwise retransmit to the same host.
        if self.icmp_enabled:
            self._drain_errqueue()
        self._drain_data()

    def _drain_data(self):
        while True:
            try:
                data, addr = self.sock.recvfrom(4096)
            except (BlockingIOError, InterruptedError):
                return
            except ConnectionRefusedError:
                # A queued ICMP error surfaced on the normal path; its detail
                # comes via the error queue, so keep draining.
                continue
            except OSError:
                return
            try:
                resp = decode_response(data)
            except (IndexError, ValueError):
                continue
            if resp is None:
                continue
            # Peek, don't pop: a reply whose source IP doesn't match (stray,
            # spoofed, or a multi-homed device answering from another
            # interface) must not destroy the real in-flight entry, which still
            # needs to retransmit/time out normally.
            entry = self.pending.get(resp.request_id)
            if entry is None or addr[0] != entry.target.ip:
                continue
            del self.pending[resp.request_id]
            if entry.deadline <= time.monotonic():
                # Reply arrived after its own timeout deadline: it was in the
                # socket but not serviced in time. We still record it (the
                # pending entry survived), but count it for visibility.
                self.late_replies += 1
            self._record(entry.target, resp)

    def _drain_errqueue(self):
        while True:
            try:
                _data, ancdata, _flags, address = self.sock.recvmsg(
                    2048, 1024, MSG_ERRQUEUE)
            except (BlockingIOError, InterruptedError):
                return
            except OSError:
                return
            ip, status = parse_icmp_error(ancdata, address)
            if ip and status:
                self._record_icmp(ip, status)

    # -- result recording ---------------------------------------------------
    def _deliver(self, record):
        """Hand a result to on_result, isolating callback faults.

        Results arrive from both the receive path and the reaper, so an
        unguarded raise would kill one or the other and abort the scan.
        Ordinary exceptions are counted (the first logged with its traceback)
        and the scan continues; BaseException -- CancelledError,
        KeyboardInterrupt, SystemExit -- is left to propagate.
        """
        try:
            self.on_result(record)
        except Exception:
            self.callback_errors += 1
            if self.callback_errors == 1:
                print(
                    'warning: on_result callback raised (subsequent errors '
                    'are counted only):',
                    file=sys.stderr)
                traceback.print_exc()

    def _record(self, target, resp):
        if target.ip in self.responders or target.ip in self.unreachable:
            return
        result = {
            'ip': target.ip,
            'subnet': target.subnet,
            'status': 'responder',
            'community': target.community,
            'version': VERSION_LABEL.get(resp.version, str(resp.version)),
            'error_status': resp.error_status,
        }
        for oid, value in resp.varbinds:
            result[OID_TO_FIELD.get(oid, oid)] = value
        self.responders[target.ip] = result
        self._deliver(result)

    def _record_icmp(self, ip, status):
        # Only terminal statuses mean "not an SNMP responder; stop probing this
        # host". Transient ones (ttl-exceeded, unknown icmp types) must not
        # resolve the host -- otherwise a routing blip would cut its community
        # fallback short or suppress a reply still in flight.
        if status not in CLOSED_STATUSES:
            return
        if ip in self.responders or ip in self.unreachable:
            return
        self.unreachable[ip] = status
        if self.emit_icmp:
            label = None
            if self.label_for:
                # Like on_result, label_for is foreign code on the loop thread;
                # a fault must not abort the scan -- fall back to no label.
                try:
                    label = self.label_for(ip)
                except Exception:
                    label = None
            self._deliver({'ip': ip, 'subnet': label, 'status': status})

    # -- send helpers -------------------------------------------------------
    def _next_rid(self):
        self._rid = (self._rid + 1) & 0x7FFFFFFF
        if self._rid == 0:
            self._rid = 1
        return self._rid

    async def _send(self, packet, ip):
        drained = False
        while True:
            try:
                self.sock.sendto(packet, (ip, self.config.port))
                return True
            except BlockingIOError:
                await asyncio.sleep(0.001)
            except OSError:
                # On an IP_RECVERR socket a sendto can surface a queued ICMP
                # error from a PRIOR datagram to a different host -- it is not
                # attributable to this target. Drain the error queue once
                # (recording it against the right host) and retry before giving
                # up, so an unrelated reachable host is not dropped.
                if self.icmp_enabled and not drained:
                    drained = True
                    self._drain_errqueue()
                    continue
                self.send_errors += 1
                return False

    def _version_for(self, attempt):
        """SNMP version for a 1-based attempt number.

        Every attempt uses primary_version except, when a fallback is set, the
        final one (retries + 1) -- the v1-fallback piggyback. Fallback requires
        retries >= 1 so the primary version always gets at least one clean try.
        """
        if (self.fallback_version is not None and attempt == self.config.retries + 1):
            return self.fallback_version
        return self.primary_version

    def _reap(self, now):
        """Service arrived replies, then retransmit or drop timed-out requests.

        Draining first is deliberate: a reply that was sitting unserviced in
        the socket gets recorded (its host marked found) before we would time
        it out or fall through to the v1 attempt -- so servicing lag never
        causes a spurious timeout or version downgrade.
        """
        if self.icmp_enabled:
            self._drain_errqueue()
        self._drain_data()
        expired = [rid for rid, e in self.pending.items() if e.deadline <= now]
        for rid in expired:
            e = self.pending.pop(rid)
            if e.target.ip in self.responders or e.target.ip in self.unreachable:
                continue  # already resolved; don't retransmit
            if e.tries > self.config.retries:
                continue  # give up (no responder)
            attempt = e.tries + 1
            packet = assemble_get(rid, e.target.community, _SCAN_VARBINDS,
                                  self._version_for(attempt))
            try:
                self.sock.sendto(packet, (e.target.ip, self.config.port))
                self.retransmits += 1
                self.pending[rid] = e._replace(tries=attempt,
                                               deadline=now + self.config.timeout)
            except BlockingIOError:
                self.pending[rid] = e._replace(deadline=now)  # retry next reap
            except OSError:
                # As in _send: a surfaced queued ICMP error (IP_RECVERR) may be
                # from an unrelated host. Drain it and retry the retransmit once
                # before dropping this entry.
                if self.icmp_enabled:
                    self._drain_errqueue()
                    try:
                        self.sock.sendto(packet, (e.target.ip, self.config.port))
                        self.retransmits += 1
                        self.pending[rid] = e._replace(tries=attempt,
                                                       deadline=now +
                                                       self.config.timeout)
                        continue
                    except OSError:
                        pass
                self.send_errors += 1

    # -- main loop ----------------------------------------------------------
    async def scan(self, targets):
        """Probe an iterable of Target, pacing sends to the configured rate.

        Targets whose IP is already resolved (found, or shown closed via ICMP)
        are skipped -- the per-host "stop after the first community that works"
        short-circuit, which works for any producer that groups a host's
        targets together.
        """
        interval = self.config.burst / self.config.rate
        since_pause = 0
        for target in targets:
            if target.ip in self.responders or target.ip in self.unreachable:
                continue
            # Bound outstanding requests -> bounded memory, gentler on the net.
            while len(self.pending) >= self.config.max_inflight:
                self._reap(time.monotonic())
                await asyncio.sleep(0.001)
            rid = self._next_rid()
            packet = assemble_get(rid, target.community, _SCAN_VARBINDS,
                                  self._version_for(1))
            if await self._send(packet, target.ip):
                self.pending[rid] = Pending(target,
                                            time.monotonic() + self.config.timeout, 1)
                self.sent += 1
            since_pause += 1
            if since_pause >= self.config.burst:
                since_pause = 0
                self._reap(time.monotonic())
                await asyncio.sleep(interval)
        # Drain: keep reaping/awaiting until every request resolves or dies.
        while self.pending:
            self._reap(time.monotonic())
            await asyncio.sleep(0.01)


async def run(config, on_result, targets, label_for=None):
    """Open a Prober, scan the target stream, and close it.

    Returns (Prober, elapsed_seconds).
    """
    loop = asyncio.get_running_loop()
    prober = Prober(config, on_result, label_for)
    start = time.monotonic()
    try:
        # Inside the try so a failure in open() (e.g. bind/add_reader after the
        # socket fd exists) still hits close() and releases the fd.
        prober.open(loop)
        await prober.scan(targets)
    finally:
        prober.close(loop)
    return prober, time.monotonic() - start
