"""routeros.introspect — Discover and cache the full RouterOS API schema.

Two strategies are used in order:

1. **REST-API inspect.json** (preferred) — if a pre-downloaded
   ``data/mikrotik/rest-api/<version>/inspect.json`` exists next to the
   project root, it is loaded and merged with the ``extra/inspect.json``
   (when present) into a single nested tree.  These files cover
   RouterOS 7.9 – 7.22+.

2. **Binary-API BFS** (fallback) — walks the API tree via
   ``/console/inspect`` on the live router.  Produces the same nested
   tree format, querying ``request=syntax`` per command to obtain
   parameter names and descriptions.

Output format mirrors ``inspect.json`` exactly::

    {
      "ip": {
        "_type": "dir",
        "address": {
          "_type": "dir",
          "add": {
            "_type": "cmd",
            "address": {"_type": "arg", "desc": "A.B.C.D ..."},
            "interface": {"_type": "arg"}
          },
          "print": {"_type": "cmd", "brief": {"_type": "arg"}, ...}
        }
      }
    }

Usage::

    import routeros

    with routeros.dial("192.168.1.1:8728", "admin", "secret") as c:
        schema = routeros.discover_api(c)
        print(schema["version"], schema["endpoint_count"])
        # Access the nested tree:
        add_cmd = schema["tree"]["ip"]["address"]["add"]

    # Later, without a connection:
    schema = routeros.load_api_schema("7.17")
"""

from __future__ import annotations

import datetime
import json
import logging
from collections import deque
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from routeros.client import Client

from routeros.errors import RouterOSError

# ---------------------------------------------------------------------------
# Default paths
# ---------------------------------------------------------------------------

# src/routeros/introspect.py
#   .parent          → src/routeros/
#   .parent.parent   → src/
#   .parent.parent.parent → routeros/   (project root inside the mono-repo)
_PROJECT_ROOT = Path(__file__).parent.parent.parent
_DEFAULT_DATA_DIR = _PROJECT_ROOT / "data" / "mikrotik" / "api"
_REST_API_DIR = _PROJECT_ROOT / "data" / "mikrotik" / "rest-api"

_log = logging.getLogger("routeros.introspect")

# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def discover_api(
    client: "Client",
    *,
    data_dir: str | Path | None = None,
    force: bool = False,
    logger: logging.Logger | None = None,
) -> dict:
    """Walk the RouterOS API tree and persist the schema to JSON.

    Parameters
    ----------
    client:
        A connected, logged-in :class:`~routeros.client.Client`.
    data_dir:
        Directory where JSON files are stored.  Defaults to
        ``<project_root>/data/mikrotik/api/``
    force:
        Re-discover even if the file already exists.
    logger:
        Optional logger; falls back to ``logging.getLogger("routeros.introspect")``.

    Returns
    -------
    dict
        Schema with keys ``version``, ``generated_at``, ``endpoint_count``,
        and ``tree`` (nested inspect.json-compatible tree).
    """
    log = logger or _log
    dir_path = Path(data_dir) if data_dir else _DEFAULT_DATA_DIR
    dir_path.mkdir(parents=True, exist_ok=True)

    version = _get_version(client, log)
    out_path = dir_path / f"routeros_{version}.json"

    if not force and out_path.exists():
        log.info("Loading existing schema from %s", out_path)
        return json.loads(out_path.read_text(encoding="utf-8"))

    log.info("Discovering RouterOS %s API schema …", version)
    tree = _discover_tree(version, client, log)

    schema = {
        "version": version,
        "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        "endpoint_count": _count_dir_nodes(tree),
        "tree": tree,
    }
    out_path.write_text(json.dumps(schema, indent=2, ensure_ascii=False), encoding="utf-8")
    log.info("Saved %d endpoints to %s", schema["endpoint_count"], out_path)
    return schema


def load_api_schema(
    version: str,
    *,
    data_dir: str | Path | None = None,
) -> dict | None:
    """Load a previously saved API schema by version string.

    Returns ``None`` if no file exists for that version.
    """
    dir_path = Path(data_dir) if data_dir else _DEFAULT_DATA_DIR
    out_path = dir_path / f"routeros_{version}.json"
    if not out_path.exists():
        return None
    return json.loads(out_path.read_text(encoding="utf-8"))


# ---------------------------------------------------------------------------
# Discovery orchestration
# ---------------------------------------------------------------------------


def _discover_tree(
    version: str,
    client: "Client",
    log: logging.Logger,
) -> dict:
    """Try REST inspect.json first, then fall back to binary-API BFS."""
    tree = _try_rest_inspect_json(version, log)
    if tree is not None:
        log.info("Loaded REST-API inspect.json: %d dir nodes", _count_dir_nodes(tree))
        return tree

    log.info(
        "No inspect.json for version %s — using binary-API BFS", version
    )
    return _walk_api_binary(client, log)


# ---------------------------------------------------------------------------
# Strategy 1: load and merge REST-API inspect.json files
# ---------------------------------------------------------------------------


def _try_rest_inspect_json(version: str, log: logging.Logger) -> dict | None:
    """Return merged nested tree from inspect.json files, or None if not found."""
    main_data, extra_data = _load_inspect_pair(_REST_API_DIR / version, log)

    # Try partial version match (e.g., "7.17" matches "7.17.1")
    if main_data is None and extra_data is None and _REST_API_DIR.exists():
        for subdir in sorted(_REST_API_DIR.iterdir()):
            dir_version = subdir.name
            if dir_version.startswith(version + ".") or dir_version == version:
                main_data, extra_data = _load_inspect_pair(subdir, log)
                if main_data is not None or extra_data is not None:
                    log.debug(
                        "Using inspect.json for version %s (wanted %s)",
                        dir_version,
                        version,
                    )
                    break

    if main_data is None and extra_data is None:
        return None
    if main_data is not None and extra_data is not None:
        return _deep_merge(main_data, extra_data)
    return main_data if main_data is not None else extra_data


def _load_inspect_pair(
    version_dir: Path,
    log: logging.Logger,
) -> tuple[dict | None, dict | None]:
    """Load main and extra inspect.json from a version directory."""
    main_data = _load_json_file(version_dir / "inspect.json", log)
    extra_data = _load_json_file(version_dir / "extra" / "inspect.json", log)
    return main_data, extra_data


def _load_json_file(path: Path, log: logging.Logger) -> dict | None:
    """Load a JSON file and return its contents, or None on failure."""
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        log.debug("Loaded %s", path)
        return data
    except Exception as exc:
        log.warning("Failed to parse %s: %s", path, exc)
        return None


# ---------------------------------------------------------------------------
# Strategy 2: binary-API BFS via /console/inspect
# ---------------------------------------------------------------------------


def _walk_api_binary(client: "Client", log: logging.Logger) -> dict:
    """Full BFS walk using the binary RouterOS API /console/inspect.

    Builds a nested tree in the same format as ``inspect.json``::

        {
          "ip": {
            "_type": "dir",
            "address": {
              "_type": "dir",
              "add": {
                "_type": "cmd",
                "address": {"_type": "arg", "desc": "A.B.C.D ..."}
              }
            }
          }
        }

    The binary API's ``/console/inspect`` accepts **comma-separated** path
    components (e.g. ``ip,address`` or ``ip,firewall,filter``).

    Each queue entry is a tuple ``(api_path, output_path)`` where:

    * ``api_path`` — comma-separated for the ``path=`` parameter.
    * ``output_path`` — slash-separated for the nested tree navigation.
    """
    root: dict = {}

    # (api_path, output_path)
    queue: deque[tuple[str, str]] = deque([("", "")])
    visited: set[str] = set()

    while queue:
        api_path, out_path = queue.popleft()

        if api_path in visited:
            continue
        visited.add(api_path)

        log.debug("Binary-API inspect: %r", api_path or "<root>")

        try:
            child_reply = client.run(
                "/console/inspect",
                "=request=child",
                f"=path={api_path}",
            )
        except RouterOSError as exc:
            log.debug("  request=child failed for %r: %s", api_path, exc)
            continue

        cmd_names: list[str] = []

        for sentence in child_reply.re:
            item_type = sentence.map.get("type", "")
            name = sentence.map.get("name", "")
            node_type = sentence.map.get("node-type", "")

            if item_type == "self" or not name:
                continue

            child_api = f"{api_path},{name}" if api_path else name
            child_out = f"{out_path}/{name}" if out_path else name

            if node_type in ("path", "dir"):
                if child_api not in visited:
                    queue.append((child_api, child_out))
            elif node_type == "cmd":
                cmd_names.append(name)

        if out_path:  # skip root
            node = _get_or_create_node(root, out_path)
            node["_type"] = "dir"

            for cmd_name in sorted(cmd_names):
                cmd_api = f"{api_path},{cmd_name}" if api_path else cmd_name
                cmd_node: dict = {"_type": "cmd"}
                try:
                    syntax_reply = client.run(
                        "/console/inspect",
                        "=request=syntax",
                        f"=path={cmd_api}",
                    )
                    for s in syntax_reply.re:
                        symbol = s.map.get("symbol", "")
                        if symbol and symbol != "..":
                            arg_node: dict = {"_type": "arg"}
                            desc = s.map.get("text", "")
                            if desc:
                                arg_node["desc"] = desc
                            cmd_node[symbol] = arg_node
                except (RouterOSError, OSError):
                    pass
                node[cmd_name] = cmd_node

    log.info("Binary-API BFS complete: %d dir nodes", _count_dir_nodes(root))
    return root


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


def _get_version(client: "Client", log: logging.Logger) -> str:
    """Return the RouterOS version string from /system/resource/print.

    Normalises the version to the dotted-number prefix only, dropping any
    suffix such as " (long-term)" or " (stable)" that RouterOS appends.

    Examples::

        "7.20.8 (long-term)"  →  "7.20.8"
        "7.17"                →  "7.17"
    """
    reply = client.run("/system/resource/print")
    for sentence in reply.re:
        version = sentence.map.get("version")
        if version:
            version = version.split()[0]
            log.debug("RouterOS version: %s", version)
            return version
    raise RouterOSError(
        "Could not determine RouterOS version from /system/resource/print"
    )


def _deep_merge(base: dict, override: dict) -> dict:
    """Deep-merge *override* into *base*, returning a new dict.

    Dict values are merged recursively; all other values from *override*
    take precedence over *base*.
    """
    result = dict(base)
    for key, val in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
            result[key] = _deep_merge(result[key], val)
        else:
            result[key] = val
    return result


def _count_dir_nodes(tree: dict) -> int:
    """Count navigable path nodes (``_type`` in ``"dir"`` or ``"path"``) in the tree."""
    count = 0
    for val in tree.values():
        if not isinstance(val, dict):
            continue
        node_type = val.get("_type", "")
        if node_type in ("dir", "path"):
            count += 1 + _count_dir_nodes(val)
        # Do not recurse into "cmd" or "arg" nodes — they hold args, not dirs.
    return count


def _get_or_create_node(root: dict, path: str) -> dict:
    """Navigate to (creating if needed) the node at the slash-separated *path*."""
    node = root
    for part in path.split("/"):
        if part not in node:
            node[part] = {}
        node = node[part]
    return node
