"""Bot-logic parsing for the diagnosis feature.

The Control Room returns bot logic as a JSON tree from
``GET /v2/repository/files/{fileId}/content``. The exact node schema is
**[UNVERIFIED]** from the OpenAPI spec — field names for the child array, the
command identifier, and the line number vary across Control Room versions. This
module therefore walks the tree *permissively*: it feature-detects each field
at runtime and never hard-fails on an unexpected shape.

What this module produces is the **diagnostic surface**: a flat, ordered list
of action nodes, each carrying a stable path (the sequence of child indices
from the root) plus whatever identity fields the node happened to expose
(command, name, line number). The raw bot JSON is **never** returned into tool
context by the diagnosis tool — only this parsed surface and a disk path are.

The two design invariants:

* **Permissive parsing.** Unknown keys are ignored, not errors. A node missing
  every known field still becomes a ``BotNode`` (with ``None`` fields) so its
  position in the order is preserved — line numbers are the primary match key
  but the order is the fallback.
* **Iterative DFS.** Bots can nest deeply (loops inside Try/Catch inside
  loops). Recursion would risk the stack on a hostile bot; an explicit stack
  keeps the walk bounded and debuggable.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from typing import Any

from ..auth.auth_manager import AuthManager
from ..tools.a360_api import get_record

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #

BOT_CONTENT_PATH = "/v2/repository/files/{file_id}/content"

# Permissive key sets — the first match in each category wins. The exact field
# names are [UNVERIFIED] across CR versions, so we try every plausible spelling.
CHILD_KEYS: tuple[str, ...] = ("commands", "children", "nodes", "body", "steps")
COMMAND_KEYS: tuple[str, ...] = (
    "command",
    "commandName",
    "type",
    "kind",
    "commandId",
)
NAME_KEYS: tuple[str, ...] = ("name", "label", "displayName", "comment", "description")
LINE_KEYS: tuple[str, ...] = ("lineNumber", "line_number", "line", "lineNo")
INDEX_KEYS: tuple[str, ...] = ("index", "indexInParent", "order", "sequence")

# Kind detection operates on the command's *tokens*, never on bare substrings:
# "Registry" contains "try", "Perform" contains "for", "Modify" contains "if".
# A false "try" is the expensive one — it makes has_error_handler() claim a
# handler that is not there and suppresses the instrumentation prescription.
TRY_MARKERS: tuple[str, ...] = ("try", "trycatch", "errorhandling", "errorhandler")
CATCH_MARKERS: tuple[str, ...] = ("catch",)
FINALLY_MARKERS: tuple[str, ...] = ("finally",)
LOOP_MARKERS: tuple[str, ...] = (
    "loop",
    "for",
    "while",
    "iterator",
    "each",
    "repeat",
)
CONDITION_MARKERS: tuple[str, ...] = (
    "if",
    "condition",
    "branch",
    "else",
    "elseif",
)

# Kinds that count as an error handler for the instrumentation prescription.
ERROR_HANDLER_KINDS = frozenset({"try", "catch", "finally"})

# Token splitting: camelCase boundaries first, then any run of non-alphanumerics.
# The second alternative handles acronym-to-word runs ("HTTPRequest" -> HTTP|Request).
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
_NON_ALNUM_RE = re.compile(r"[^A-Za-z0-9]+")

KIND_ACTION = "action"
KIND_TRY = "try"
KIND_CATCH = "catch"
KIND_FINALLY = "finally"
KIND_LOOP = "loop"
KIND_CONDITION = "condition"
KIND_UNKNOWN = "unknown"


# --------------------------------------------------------------------------- #
# Typed shape
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class BotNode:
    """One node in the flattened bot action tree.

    ``path`` is the sequence of child indices from the root (e.g. ``(2, 0)`` =
    the first child of the third top-level command). It is the stable
    identifier when no line number is available. ``order`` is the flat DFS
    discovery index — useful for "the Nth action".
    """

    order: int
    path: tuple[int, ...]
    command: str | None
    name: str | None
    line_number: int | None
    index: int | None
    kind: str
    depth: int
    has_children: bool
    attributes: dict = field(default_factory=dict)


# --------------------------------------------------------------------------- #
# Kind detection (pure)
# --------------------------------------------------------------------------- #
def command_tokens(command: str) -> tuple[str, ...]:
    """Split a command identifier into lowercase word tokens.

    Handles every spelling the Control Room uses for the same construct:
    ``Try_Catch``, ``TryCatch``, ``errorHandling.Try``, ``TRY`` all reduce to
    tokens containing ``try``. Splitting on camelCase boundaries as well as
    punctuation is what keeps ``Registry`` from reading as a Try node.
    """
    spaced = _CAMEL_BOUNDARY_RE.sub(" ", command)
    return tuple(token.lower() for token in _NON_ALNUM_RE.split(spaced) if token)


def _matches(tokens: tuple[str, ...], markers: tuple[str, ...]) -> bool:
    """True if any marker is a whole token, or spells the whole command.

    The second case covers single-token spellings of multi-word constructs:
    ``ErrorHandling`` tokenizes to ``("error", "handling")``, which no single
    marker equals, but whose concatenation does.
    """
    if any(marker in tokens for marker in markers):
        return True
    return "".join(tokens) in markers


def detect_kind(command: str | None) -> str:
    """Classify a node by its command string.

    Matches markers against whole *tokens* (see :func:`command_tokens`), not
    substrings. Substring matching used to classify ``Registry: Get value`` as
    a Try node and ``Perform action`` as a loop; a spurious Try is the costly
    one, because ``has_error_handler`` then reports a handler the bot does not
    have and the instrumentation prescription is silently dropped.

    TRY is checked *before* CATCH because composite names like ``Try_Catch``
    name the whole construct — the catch *clause* is named ``Catch`` alone,
    which carries no try token.
    """
    if not command:
        return KIND_UNKNOWN
    tokens = command_tokens(command)
    if not tokens:
        return KIND_UNKNOWN
    if _matches(tokens, TRY_MARKERS):
        return KIND_TRY
    if _matches(tokens, CATCH_MARKERS):
        return KIND_CATCH
    if _matches(tokens, FINALLY_MARKERS):
        return KIND_FINALLY
    if _matches(tokens, LOOP_MARKERS):
        return KIND_LOOP
    if _matches(tokens, CONDITION_MARKERS):
        return KIND_CONDITION
    return KIND_ACTION


# --------------------------------------------------------------------------- #
# Field extraction (pure helpers)
# --------------------------------------------------------------------------- #
def _pick_str(node: dict, keys: tuple[str, ...]) -> str | None:
    """First non-empty string value among ``keys``."""
    for key in keys:
        value = node.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return None


def _pick_int(node: dict, keys: tuple[str, ...]) -> int | None:
    """First positive int value among ``keys`` (string digits accepted)."""
    for key in keys:
        value = node.get(key)
        if isinstance(value, bool):
            continue
        if isinstance(value, int) and value > 0:
            return value
        if isinstance(value, str) and value.strip().isdigit():
            parsed = int(value.strip())
            if parsed > 0:
                return parsed
    return None


def _children_of(node: dict) -> list:
    """Return the first list-typed value found among :data:`CHILD_KEYS`.

    The child array key is [UNVERIFIED]; this resolves whichever spelling the
    CR uses. A non-list value is ignored (some keys carry scalar metadata).
    """
    for key in CHILD_KEYS:
        value = node.get(key)
        if isinstance(value, list):
            return value
    return []


def _node_attributes(node: dict) -> dict:
    """Pull the parameter/attribute sub-dict off a node, if present.

    Command parameters usually live under ``attributes``/``parameters``/``props``.
    Returned as-is (shallow copy) for the diagnostic surface; never the whole
    node, which may carry large nested data.
    """
    for key in ("attributes", "parameters", "props", "properties"):
        value = node.get(key)
        if isinstance(value, dict):
            return dict(value)
    return {}


# --------------------------------------------------------------------------- #
# Walk (iterative DFS)
# --------------------------------------------------------------------------- #
def walk_actions(bot_json: Any) -> list[BotNode]:
    """Flatten a bot JSON tree into an ordered list of :class:`BotNode`.

    Iterative DFS so a deeply nested bot cannot overflow the stack. Each node
    gets a stable ``path`` (child-index sequence from root) and a flat
    ``order``. Nodes with no recognizable identity still appear so positional
    matching (``order``) stays reliable when line numbers are absent.

    Accepts either a bare list of top-level commands or a dict that *contains*
    a child array — both shapes occur in practice.
    """
    if isinstance(bot_json, list):
        roots = bot_json
    elif isinstance(bot_json, dict):
        # A dict that carries a command identifier is a single command node
        # (possibly with children) — treat it as one root so it is not dropped.
        # A bare container (``{"commands": [...]}`` with no command key) yields
        # its child array instead.
        if _looks_like_command(bot_json):
            roots = [bot_json]
        else:
            roots = _children_of(bot_json)
    else:
        return []

    nodes: list[BotNode] = []
    order = 0
    # Stack entries: (node_dict, path_tuple, depth)
    stack: list[tuple[dict, tuple[int, ...], int]] = []
    # Push roots in reverse so they pop in document order.
    for i in range(len(roots) - 1, -1, -1):
        child = roots[i]
        if isinstance(child, dict):
            stack.append((child, (i,), 0))

    while stack:
        node, path, depth = stack.pop()
        command = _pick_str(node, COMMAND_KEYS)
        children = _children_of(node)
        bot_node = BotNode(
            order=order,
            path=path,
            command=command,
            name=_pick_str(node, NAME_KEYS),
            line_number=_pick_int(node, LINE_KEYS),
            index=_pick_int(node, INDEX_KEYS),
            kind=detect_kind(command),
            depth=depth,
            has_children=bool(children),
            attributes=_node_attributes(node),
        )
        nodes.append(bot_node)
        order += 1
        # Push children in reverse for document-order traversal.
        for i in range(len(children) - 1, -1, -1):
            child = children[i]
            if isinstance(child, dict):
                stack.append((child, path + (i,), depth + 1))
    return nodes


def _looks_like_command(node: dict) -> bool:
    """Heuristic: does this dict carry a command identifier?"""
    return any(_pick_str(node, (key,)) is not None for key in COMMAND_KEYS)


# --------------------------------------------------------------------------- #
# Error-handler + context helpers (operate on a node list)
# --------------------------------------------------------------------------- #
def has_error_handler(nodes: list[BotNode]) -> bool:
    """True if the bot contains any Try/Catch/Finally node.

    Drives the instrumentation prescription: a bot with no error handler
    cannot be diagnosed post-hoc, so the diagnosis tool emits a *prescription*
    rather than "undeterminable".
    """
    return any(node.kind in ERROR_HANDLER_KINDS for node in nodes)


def find_node_by_line(nodes: list[BotNode], line_number: int | None) -> BotNode | None:
    """Return the first node whose ``line_number`` matches.

    Returns ``None`` when ``line_number`` is falsy or no node carries it. The
    diagnosis flow falls back to ``find_node_by_order`` in that case.
    """
    if not line_number:
        return None
    for node in nodes:
        if node.line_number == line_number:
            return node
    return None


def find_node_by_order(nodes: list[BotNode], order: int | None) -> BotNode | None:
    """Return the node at flat ``order`` index, or ``None`` if out of range."""
    if order is None or order < 0 or order >= len(nodes):
        return None
    return nodes[order]


def context_window(
    nodes: list[BotNode],
    target: BotNode,
    *,
    before: int = 3,
    after: int = 3,
) -> dict[str, list[BotNode]]:
    """Return the sibling-actions surrounding ``target`` by flat order.

    "Before" and "after" are by DFS discovery order, which mirrors execution
    order for linear bots. This is the surface the diagnosis flow uses to spot
    a missing upstream validation (e.g. no file-exists check before an Open).
    """
    start = max(0, target.order - before)
    end = min(len(nodes), target.order + after + 1)
    window = nodes[start:end]
    return {
        "before": [n for n in window if n.order < target.order],
        "after": [n for n in window if n.order > target.order],
    }


def node_label(node: BotNode) -> str:
    """Human-readable one-line label for a node, used in verdict text."""
    if node.name:
        return f"'{node.name}'"
    if node.command:
        return f"<{node.command}>"
    return f"action #{node.order}"


# --------------------------------------------------------------------------- #
# Network helper (thin wrapper)
# --------------------------------------------------------------------------- #
def fetch_bot_content(auth: AuthManager, file_id: str) -> dict:
    """GET ``/v2/repository/files/{fileId}/content`` and return the parsed JSON.

    Raises ``A360ApiError`` on HTTP errors; lets ``A360AuthError`` propagate.
    The caller is responsible for *not* dumping the result into tool context
    verbatim — walk it into a node list first.
    """
    if not file_id:
        raise ValueError("file_id is required")
    path = BOT_CONTENT_PATH.format(file_id=file_id)
    return get_record(auth, path)
