"""Tests for the HgwPoller: counter->delta math, recognition backoff, credential
ordering/filtering, the MikroTik probe, and an end-to-end poll.

Fixtures are synthetic; the poller's transport is faked so no device is contacted.
"""
import asyncio
import os
import sys
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))

import hgw
import mikrotik_hgw
from credentials import Credential


def _run(co):
    return asyncio.new_event_loop().run_until_complete(co)


class TestCounterMath(unittest.TestCase):
    """_with_deltas / _annotate_counters: first-sight null, deltas, reset, rates."""

    def _client(self, retrans, hwframes, brecv, mac="AA:BB:CC:00:00:01"):
        return {"wifiClients": [{
            "mac": mac, "retrans": retrans, "hwFramesSent": hwframes,
            "bytesRecv": brecv, "linkMbps": 100,
        }]}

    def test_first_sight_is_null(self):
        p = hgw.HgwPoller("192.0.2.1", [])
        out = p._with_deltas(self._client(100, 1000, 500))
        w = out["wifiClients"][0]
        self.assertIsNone(w["retransDelta"])
        self.assertIsNone(w["hwFramesSentDelta"])
        self.assertIsNone(w["bytesRecvDelta"])
        self.assertNotIn("retransRate", w)          # needs both deltas numeric
        self.assertIsNone(out["_intervalSec"])       # no prior sample

    def test_delta_and_rate_on_second_poll(self):
        p = hgw.HgwPoller("192.0.2.1", [])
        p._with_deltas(self._client(100, 1000, 500))
        out = p._with_deltas(self._client(150, 1500, 800))
        w = out["wifiClients"][0]
        self.assertEqual(w["retransDelta"], 50)
        self.assertEqual(w["hwFramesSentDelta"], 500)
        self.assertEqual(w["bytesRecvDelta"], 300)
        self.assertEqual(w["retransRate"], round(50 / 500, 4))  # 0.1
        self.assertIsInstance(out["_intervalSec"], float)

    def test_counter_reset_returns_raw_value(self):
        p = hgw.HgwPoller("192.0.2.1", [])
        p._with_deltas(self._client(100, 1000, 500))
        out = p._with_deltas(self._client(10, 1200, 600))   # retrans dropped: reset
        w = out["wifiClients"][0]
        self.assertEqual(w["retransDelta"], 10)             # raw, not negative
        self.assertEqual(w["hwFramesSentDelta"], 200)

    def test_items_matched_by_mac_not_position(self):
        p = hgw.HgwPoller("192.0.2.1", [])
        p._with_deltas({"wifiClients": [
            self._one("AA:BB:CC:00:00:01", 100),
            self._one("AA:BB:CC:00:00:02", 40),
        ]})
        out = p._with_deltas({"wifiClients": [            # order swapped
            self._one("AA:BB:CC:00:00:02", 55),
            self._one("AA:BB:CC:00:00:01", 130),
        ]})
        by_mac = {w["mac"]: w for w in out["wifiClients"]}
        self.assertEqual(by_mac["AA:BB:CC:00:00:01"]["retransDelta"], 30)
        self.assertEqual(by_mac["AA:BB:CC:00:00:02"]["retransDelta"], 15)

    @staticmethod
    def _one(mac, retrans):
        return {"mac": mac, "retrans": retrans, "hwFramesSent": 1000, "bytesRecv": 1}


class TestBackoff(unittest.TestCase):
    """Recognition backoff: skip pattern, miss cap, reset on recognition."""

    def _unrecognized_poller(self):
        p = hgw.HgwPoller("192.0.2.1", [])

        async def none():
            return None
        p._ensure_client = none
        return p

    def test_skip_pattern_and_probe_count(self):
        p = self._unrecognized_poller()
        probes = {"n": 0}
        base = p._ensure_client

        async def counting():
            probes["n"] += 1
            return await base()
        p._ensure_client = counting

        async def go():
            probed = []
            for _ in range(50):
                before = probes["n"]
                await p.poll()
                probed.append(probes["n"] > before)
            return probed
        probed = _run(go())
        # a probe lands, then the next is skipped by 2**(miss-1) polls: gaps grow
        # 1,2,4,8,16 (then capped), so probes fall at polls 0,2,5,10,19,36.
        idx = [i for i, hit in enumerate(probed) if hit]
        self.assertEqual(idx, [0, 2, 5, 10, 19, 36])
        self.assertEqual(probes["n"], 6)

    def test_miss_count_is_capped(self):
        p = self._unrecognized_poller()

        async def go():
            for _ in range(200):
                await p.poll()
        _run(go())
        self.assertLessEqual(p._backoff_skip, hgw._MISS_BACKOFF_CAP)
        # cap point: 2**(miss-1) == _MISS_BACKOFF_CAP, miss stops growing there
        self.assertEqual(2 ** (p._miss_count - 1), hgw._MISS_BACKOFF_CAP)

    def test_recognition_resets_backoff(self):
        p = self._unrecognized_poller()

        async def go():
            await p.poll()                       # miss -> backoff armed
            self.assertGreater(p._backoff_skip, 0)
            # now the host is recognized: swap in a live mikrotik client
            client = object()
            async def ok():
                p._transport = "mikrotik"
                return client
            p._ensure_client = ok

            async def summ(_c):
                return {"model": "X", "wifiClients": []}
            p._poll_mikrotik = summ
            # first real poll is skipped (backoff still counting down); drain it
            while p._backoff_skip > 0:
                await p.poll()
            out = await p.poll()
            return out
        out = _run(go())
        self.assertIsNotNone(out)
        self.assertEqual(p._miss_count, 0)
        self.assertEqual(p._backoff_skip, 0)


class TestCredentialOrdering(unittest.TestCase):

    def test_role_filtering(self):
        creds = [
            Credential("u1", "p1", None, None),      # no role -> matches any
            Credential("u2", "p2", None, "hgw"),     # matches hgw
            Credential("u3", "p3", None, "router"),  # excluded for an hgw poller
        ]
        p = hgw.HgwPoller("192.0.2.1", creds, role="hgw")
        got = [(c.username) for c in p._role_creds()]
        self.assertEqual(got, ["u1", "u2"])

    def test_latched_working_cred_ordered_first(self):
        creds = [Credential("u1", "p1", None, None),
                 Credential("u2", "p2", None, None)]
        p = hgw.HgwPoller("192.0.2.1", creds)
        self.assertEqual([c.username for c in p._ordered_creds(creds)], ["u1", "u2"])
        p._working = ("u2", "p2")
        self.assertEqual([c.username for c in p._ordered_creds(creds)], ["u2", "u1"])


class TestEnsureClientProbe(unittest.TestCase):
    """The MikroTik probe branch of _ensure_client (mikrotik_api_probe faked)."""

    def _fake_probe(self, expect_ports=None, succeed_on=None):
        calls = {"ports": []}

        class FakeClient:
            def __init__(self):
                self._user, self._pass = "u", "p"
            async def close(self):
                pass

        async def probe(ip, cls, creds, port=None, timeout=None):
            calls["ports"].append(port)
            if succeed_on is None or port == succeed_on:
                return FakeClient()
            return None
        return probe, calls

    def test_successful_probe_caches_and_latches(self):
        probe, _ = self._fake_probe()
        creds = [Credential("u", "p", [8728], "hgw")]
        p = hgw.HgwPoller("192.0.2.1", creds)
        orig = hgw.mikrotik_api_probe
        hgw.mikrotik_api_probe = probe
        try:
            client = _run(p._ensure_client())
        finally:
            hgw.mikrotik_api_probe = orig
        self.assertIsNotNone(client)
        self.assertEqual(p.api, "mikrotik")
        self.assertEqual(p._transport, "mikrotik")
        self.assertEqual(p._working, ("u", "p"))
        self.assertIs(p._client, client)          # cached on the instance

    def test_unrecognized_returns_none_without_creds(self):
        # only a router-role cred: filtered out for an hgw poller -> no probe, None
        p = hgw.HgwPoller("192.0.2.1", [Credential("u", "p", [8728], "router")])
        called = {"n": 0}

        async def probe(*a, **k):
            called["n"] += 1
            return None
        orig = hgw.mikrotik_api_probe
        hgw.mikrotik_api_probe = probe
        try:
            self.assertIsNone(_run(p._ensure_client()))
        finally:
            hgw.mikrotik_api_probe = orig
        self.assertEqual(called["n"], 0)          # never probed


class TestPollEndToEnd(unittest.TestCase):
    """poll() over a faked mikrotik transport, including delta annotation."""

    def _poller(self, collected):
        p = hgw.HgwPoller("192.0.2.1", [Credential("u", "p", None, "hgw")])

        async def ensure():
            p._transport = "mikrotik"
            p.api = "mikrotik"
            return object()
        p._ensure_client = ensure

        async def collect(_client):
            return collected
        # patch the module function collect() uses
        self._orig_collect = mikrotik_hgw.collect
        mikrotik_hgw.collect = collect
        return p

    def tearDown(self):
        if hasattr(self, "_orig_collect"):
            mikrotik_hgw.collect = self._orig_collect

    def _collected(self, retrans, hwframes):
        return {
            "device": {"manufacturer": "MikroTik", "model": "RB951Ui-2HnD",
                       "serial": "S", "firmwareVersion": "7", "identity": "gw",
                       "description": "RB"},
            "system": {"cpuPct": 1, "memFreeKB": 1, "memTotalKB": 2, "uptimeSec": 3},
            "internet": {"online": True, "ip": "203.0.113.5", "gateway": "203.0.113.1",
                         "dns": [], "wanInterface": "ether1"},
            "wan": [], "lan": [], "ethLinks": [], "radios": [],
            "wifiClients": [{
                "mac": "AA:BB:CC:00:00:01", "band": "2.4GHz", "interface": "wlan1",
                "rssiDbm": -55, "snr": 40, "txRateMbps": 72.2, "rxRateMbps": 58,
                "txCcq": 95, "retrans": retrans, "framesSent": hwframes - 4,
                "hwFramesSent": hwframes, "bytesSent": 1, "bytesRecv": 2,
                "packetsSent": 3, "packetsRecv": 4, "uptimeSec": 9,
                "ip": "192.168.88.10", "name": "c",
            }],
            "clients": [], "knownClientsCount": 0, "wifiMacs": ["AA:BB:CC:00:00:01"],
        }

    def test_poll_returns_summary_with_model(self):
        p = self._poller(self._collected(100, 1000))
        out = _run(p.poll())
        self.assertEqual(out["model"], "RB951Ui-2HnD")
        self.assertEqual(p.model, "RB951Ui-2HnD")
        self.assertIsNone(out["_intervalSec"])                    # first poll

    def test_poll_annotates_deltas_across_two_polls(self):
        p = self._poller(self._collected(100, 1000))

        async def two():
            await p.poll()
            # bump counters, re-poll on the same poller instance
            mikrotik_hgw.collect = self._make_collect(self._collected(150, 1500))
            return await p.poll()
        out = _run(two())
        w = out["wifiClients"][0]
        self.assertEqual(w["retransDelta"], 50)
        self.assertEqual(w["retransRate"], round(50 / 500, 4))

    @staticmethod
    def _make_collect(collected):
        async def collect(_client):
            return collected
        return collect

    def test_empty_read_returns_none(self):
        # collect returns no model -> connection considered gone -> poll re-probes
        p = self._poller({"device": {}})
        # _ensure_client always "recognizes" here, so after the re-probe it still
        # gets an empty read; poll() gives up after the second attempt.
        self.assertIsNone(_run(p.poll()))


if __name__ == "__main__":
    unittest.main()
