"""Unit tests for routeros.introspect — no live network required."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest

from routeros.introspect import (
    _count_dir_nodes,
    _deep_merge,
    _get_version,
    _try_rest_inspect_json,
    _walk_api_binary,
    discover_api,
    load_api_schema,
)
from routeros.reply import Reply
from routeros.sentence import Sentence
from tests.unit.conftest import run_server

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mock_client(*call_returns: list[dict]) -> MagicMock:
    """Return a mock Client whose .run() returns successive Replies."""
    client = MagicMock()
    replies = [_make_reply(rows) for rows in call_returns]
    client.run.side_effect = replies
    return client


def _make_reply(rows: list[dict]) -> Reply:
    """Build a Reply containing !re Sentences from a list of dicts."""
    sentences = [Sentence(word="!re", map=dict(row)) for row in rows]
    return Reply(re=sentences, done=Sentence(word="!done"))


# ---------------------------------------------------------------------------
# _deep_merge
# ---------------------------------------------------------------------------


class TestDeepMerge:
    def test_disjoint_keys(self):
        result = _deep_merge({"a": 1}, {"b": 2})
        assert result == {"a": 1, "b": 2}

    def test_override_scalar(self):
        result = _deep_merge({"a": 1}, {"a": 99})
        assert result["a"] == 99

    def test_recursive_merge(self):
        base = {"ip": {"_type": "dir", "address": {"_type": "dir"}}}
        override = {"ip": {"_type": "dir", "firewall": {"_type": "dir"}}}
        result = _deep_merge(base, override)
        assert "address" in result["ip"]
        assert "firewall" in result["ip"]

    def test_does_not_mutate_base(self):
        base = {"a": {"x": 1}}
        _deep_merge(base, {"a": {"y": 2}})
        assert "y" not in base["a"]


# ---------------------------------------------------------------------------
# _count_dir_nodes
# ---------------------------------------------------------------------------


class TestCountDirNodes:
    def test_empty(self):
        assert _count_dir_nodes({}) == 0

    def test_single_dir(self):
        tree = {"ip": {"_type": "dir"}}
        assert _count_dir_nodes(tree) == 1

    def test_nested_dirs(self):
        tree = {
            "ip": {
                "_type": "dir",
                "address": {"_type": "dir"},
            }
        }
        assert _count_dir_nodes(tree) == 2

    def test_cmd_not_counted(self):
        tree = {
            "ip": {
                "_type": "dir",
                "print": {"_type": "cmd"},
            }
        }
        assert _count_dir_nodes(tree) == 1

    def test_arg_nodes_inside_cmd_not_counted(self):
        tree = {
            "ip": {
                "_type": "dir",
                "add": {
                    "_type": "cmd",
                    "address": {"_type": "arg"},
                },
            }
        }
        assert _count_dir_nodes(tree) == 1


# ---------------------------------------------------------------------------
# _get_version
# ---------------------------------------------------------------------------


class TestGetVersion:
    def test_extracts_version(self, socket_pair):
        client_sock, srv = socket_pair

        def server():
            srv.read_sentence()
            srv.write_sentence("!re", "=version=7.20.8", "=cpu=MIPSBE")
            srv.write_sentence("!done")

        run_server(server)
        from routeros.client import Client

        c = Client(client_sock)
        import logging

        version = _get_version(c, logging.getLogger("test"))
        assert version == "7.20.8"

    def test_raises_if_no_version(self, socket_pair):
        client_sock, srv = socket_pair

        def server():
            srv.read_sentence()
            srv.write_sentence("!done")

        run_server(server)
        from routeros.client import Client
        from routeros.errors import RouterOSError

        c = Client(client_sock)
        import logging

        with pytest.raises(RouterOSError, match="Could not determine RouterOS version"):
            _get_version(c, logging.getLogger("test"))


# ---------------------------------------------------------------------------
# _try_rest_inspect_json
# ---------------------------------------------------------------------------


class TestTryRestInspectJson:
    def test_returns_none_when_no_files(self, tmp_path):
        import logging

        with patch("routeros.introspect._REST_API_DIR", tmp_path):
            result = _try_rest_inspect_json("9.99", logging.getLogger("test"))
        assert result is None

    def test_loads_exact_version(self, tmp_path):
        import logging

        rest_dir = tmp_path / "7.17"
        rest_dir.mkdir()
        inspect_data = {
            "ip": {
                "_type": "dir",
                "address": {"_type": "dir", "print": {"_type": "cmd"}},
            }
        }
        (rest_dir / "inspect.json").write_text(json.dumps(inspect_data), encoding="utf-8")

        with patch("routeros.introspect._REST_API_DIR", tmp_path):
            result = _try_rest_inspect_json("7.17", logging.getLogger("test"))

        assert result is not None
        assert "ip" in result
        assert result["ip"]["_type"] == "dir"
        assert "address" in result["ip"]

    def test_merges_extra_inspect(self, tmp_path):
        """main + extra are deep-merged into a single tree."""
        import logging

        rest_dir = tmp_path / "7.17"
        extra_dir = rest_dir / "extra"
        extra_dir.mkdir(parents=True)
        main_data = {"ip": {"_type": "dir", "address": {"_type": "dir"}}}
        extra_data = {"ip": {"_type": "dir", "firewall": {"_type": "dir"}}, "system": {"_type": "dir"}}
        (rest_dir / "inspect.json").write_text(json.dumps(main_data), encoding="utf-8")
        (extra_dir / "inspect.json").write_text(json.dumps(extra_data), encoding="utf-8")

        with patch("routeros.introspect._REST_API_DIR", tmp_path):
            result = _try_rest_inspect_json("7.17", logging.getLogger("test"))

        assert result is not None
        assert "address" in result["ip"]   # from main
        assert "firewall" in result["ip"]  # from extra
        assert "system" in result          # from extra

    def test_loads_extra_only_when_no_main(self, tmp_path):
        import logging

        rest_dir = tmp_path / "7.17"
        extra_dir = rest_dir / "extra"
        extra_dir.mkdir(parents=True)
        inspect_data = {"system": {"_type": "dir", "print": {"_type": "cmd"}}}
        (extra_dir / "inspect.json").write_text(json.dumps(inspect_data), encoding="utf-8")

        with patch("routeros.introspect._REST_API_DIR", tmp_path):
            result = _try_rest_inspect_json("7.17", logging.getLogger("test"))

        assert result is not None
        assert "system" in result
        assert result["system"]["_type"] == "dir"

    def test_partial_version_match(self, tmp_path):
        """'7.17' query should match '7.17.1' subdirectory."""
        import logging

        rest_dir = tmp_path / "7.17.1"
        rest_dir.mkdir()
        inspect_data = {"ip": {"_type": "dir"}}
        (rest_dir / "inspect.json").write_text(json.dumps(inspect_data), encoding="utf-8")

        with patch("routeros.introspect._REST_API_DIR", tmp_path):
            result = _try_rest_inspect_json("7.17", logging.getLogger("test"))

        assert result is not None
        assert "ip" in result


# ---------------------------------------------------------------------------
# _walk_api_binary
# ---------------------------------------------------------------------------


class TestWalkApiBinary:
    def test_full_depth_walk(self, socket_pair):
        """Binary BFS builds a nested tree with per-command parameters."""
        client_sock, srv = socket_pair

        def server():
            # 1st call: request=child path="" (root) — returns ip as dir
            srv.read_sentence()
            srv.write_sentence("!re", "=type=child", "=name=ip", "=node-type=path")
            srv.write_sentence("!done")

            # 2nd call: request=child path=ip — address as dir, print as cmd
            srv.read_sentence()
            srv.write_sentence("!re", "=type=self", "=name=ip", "=node-type=path")
            srv.write_sentence("!re", "=type=child", "=name=address", "=node-type=dir")
            srv.write_sentence("!re", "=type=child", "=name=print", "=node-type=cmd")
            srv.write_sentence("!done")

            # 3rd call: request=syntax path=ip,print
            srv.read_sentence()
            srv.write_sentence("!re", "=symbol=detail", "=text=Detailed output")
            srv.write_sentence("!done")

            # 4th call: request=child path=ip,address
            srv.read_sentence()
            srv.write_sentence("!re", "=type=self", "=name=address", "=node-type=dir")
            srv.write_sentence("!re", "=type=child", "=name=add", "=node-type=cmd")
            srv.write_sentence("!re", "=type=child", "=name=remove", "=node-type=cmd")
            srv.write_sentence("!done")

            # 5th call: request=syntax path=ip,address,add
            srv.read_sentence()
            srv.write_sentence("!re", "=symbol=address", "=text=IP address")
            srv.write_sentence("!done")

            # 6th call: request=syntax path=ip,address,remove
            srv.read_sentence()
            srv.write_sentence("!re", "=symbol=<numbers>", "=text=Item numbers")
            srv.write_sentence("!done")

        run_server(server)
        import logging

        from routeros.client import Client

        c = Client(client_sock)
        result = _walk_api_binary(c, logging.getLogger("test"))

        # Nested tree structure
        assert "ip" in result
        assert result["ip"]["_type"] == "dir"
        assert "address" in result["ip"]
        assert result["ip"]["address"]["_type"] == "dir"

        # Commands are nested under the dir node
        assert "print" in result["ip"]
        assert result["ip"]["print"]["_type"] == "cmd"
        assert result["ip"]["address"]["add"]["_type"] == "cmd"
        assert result["ip"]["address"]["remove"]["_type"] == "cmd"

        # Parameters as arg nodes
        assert result["ip"]["print"]["detail"]["_type"] == "arg"
        assert result["ip"]["print"]["detail"]["desc"] == "Detailed output"
        assert result["ip"]["address"]["add"]["address"]["_type"] == "arg"
        assert result["ip"]["address"]["add"]["address"]["desc"] == "IP address"
        assert result["ip"]["address"]["remove"]["<numbers>"]["_type"] == "arg"

    def test_self_entries_skipped(self, socket_pair):
        """Sentences with type=self should not create extra nodes."""
        client_sock, srv = socket_pair

        def server():
            srv.read_sentence()
            srv.write_sentence("!re", "=type=self", "=name=root", "=node-type=path")
            srv.write_sentence("!re", "=type=child", "=name=system", "=node-type=path")
            srv.write_sentence("!done")

            # system — no children, no commands
            srv.read_sentence()
            srv.write_sentence("!done")

        run_server(server)
        import logging

        from routeros.client import Client

        c = Client(client_sock)
        result = _walk_api_binary(c, logging.getLogger("test"))

        assert "system" in result
        assert "root" not in result

    def test_empty_root_still_proceeds(self, socket_pair):
        """An empty root response returns an empty dict."""
        client_sock, srv = socket_pair

        def server():
            srv.read_sentence()
            srv.write_sentence("!done")

        run_server(server)
        import logging

        from routeros.client import Client

        c = Client(client_sock)
        result = _walk_api_binary(c, logging.getLogger("test"))
        assert isinstance(result, dict)

    def test_arg_without_desc_omits_desc_key(self, socket_pair):
        """Args with empty text should not have a 'desc' key."""
        client_sock, srv = socket_pair

        def server():
            srv.read_sentence()
            srv.write_sentence("!re", "=type=child", "=name=ip", "=node-type=path")
            srv.write_sentence("!done")

            srv.read_sentence()
            srv.write_sentence("!re", "=type=child", "=name=add", "=node-type=cmd")
            srv.write_sentence("!done")

            # syntax for add — param with empty text
            srv.read_sentence()
            srv.write_sentence("!re", "=symbol=address", "=text=")
            srv.write_sentence("!done")

        run_server(server)
        import logging

        from routeros.client import Client

        c = Client(client_sock)
        result = _walk_api_binary(c, logging.getLogger("test"))
        arg_node = result["ip"]["add"]["address"]
        assert arg_node["_type"] == "arg"
        assert "desc" not in arg_node  # empty desc omitted


# ---------------------------------------------------------------------------
# discover_api / load_api_schema
# ---------------------------------------------------------------------------


class TestDiscoverAndLoad:
    def test_discover_uses_cached_file(self, tmp_path):
        """discover_api returns existing file without re-discovering."""
        schema = {
            "version": "7.17",
            "generated_at": "2025-01-01T00:00:00+00:00",
            "endpoint_count": 1,
            "tree": {"ip": {"_type": "dir"}},
        }
        (tmp_path / "routeros_7.17.json").write_text(
            json.dumps(schema), encoding="utf-8"
        )

        client = MagicMock()
        client.run.return_value = _make_reply([{"version": "7.17"}])

        result = discover_api(client, data_dir=tmp_path)
        assert result["version"] == "7.17"
        assert result["endpoint_count"] == 1
        # Only called once for /system/resource/print
        assert client.run.call_count == 1

    def test_discover_saves_new_file(self, tmp_path):
        """discover_api saves a new JSON file when none exists."""
        inspect_data = {
            "ip": {
                "_type": "dir",
                "address": {"_type": "dir", "print": {"_type": "cmd"}},
            }
        }
        rest_dir = tmp_path / "rest-api" / "7.20"
        rest_dir.mkdir(parents=True)
        (rest_dir / "inspect.json").write_text(json.dumps(inspect_data), encoding="utf-8")

        api_dir = tmp_path / "api"
        api_dir.mkdir()

        client = MagicMock()
        client.run.return_value = _make_reply([{"version": "7.20"}])

        with (
            patch("routeros.introspect._DEFAULT_DATA_DIR", api_dir),
            patch("routeros.introspect._REST_API_DIR", tmp_path / "rest-api"),
        ):
            result = discover_api(client, data_dir=api_dir)

        assert (api_dir / "routeros_7.20.json").exists()
        assert result["endpoint_count"] > 0
        assert "tree" in result
        assert "ip" in result["tree"]

    def test_force_rediscovers(self, tmp_path):
        """force=True re-runs discovery even if file exists."""
        old_schema = {
            "version": "7.17",
            "generated_at": "2020-01-01T00:00:00+00:00",
            "endpoint_count": 0,
            "tree": {},
        }
        (tmp_path / "routeros_7.17.json").write_text(
            json.dumps(old_schema), encoding="utf-8"
        )

        inspect_data = {
            "ip": {
                "_type": "dir",
                "address": {"_type": "dir", "print": {"_type": "cmd"}},
            }
        }
        rest_dir = tmp_path / "rest-api" / "7.17"
        rest_dir.mkdir(parents=True)
        (rest_dir / "inspect.json").write_text(json.dumps(inspect_data), encoding="utf-8")

        client = MagicMock()
        client.run.return_value = _make_reply([{"version": "7.17"}])

        with patch("routeros.introspect._REST_API_DIR", tmp_path / "rest-api"):
            result = discover_api(client, data_dir=tmp_path, force=True)

        assert result["endpoint_count"] > 0
        assert "ip" in result["tree"]

    def test_load_existing(self, tmp_path):
        schema = {
            "version": "7.17",
            "generated_at": "2025-01-01T00:00:00+00:00",
            "endpoint_count": 2,
            "tree": {"ip": {"_type": "dir"}},
        }
        (tmp_path / "routeros_7.17.json").write_text(
            json.dumps(schema), encoding="utf-8"
        )
        result = load_api_schema("7.17", data_dir=tmp_path)
        assert result is not None
        assert result["endpoint_count"] == 2

    def test_load_missing_returns_none(self, tmp_path):
        result = load_api_schema("9.99", data_dir=tmp_path)
        assert result is None
