"""Tests for ``a360_mcp.services.bot``.

The bot JSON schema is [UNVERIFIED], so the walker is tested against several
plausible shapes (commands/children/nodes arrays, alternate command keys,
missing fields) to confirm permissive parsing.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from a360_mcp.auth.auth_manager import A360AuthError
from a360_mcp.services import bot
from a360_mcp.services.bot import (
    KIND_ACTION,
    KIND_CATCH,
    KIND_CONDITION,
    KIND_LOOP,
    KIND_TRY,
    KIND_UNKNOWN,
    BotNode,
    context_window,
    detect_kind,
    find_node_by_line,
    find_node_by_order,
    has_error_handler,
    node_label,
    walk_actions,
)
from a360_mcp.tools.a360_api import A360ApiError


# --------------------------------------------------------------------------- #
# detect_kind
# --------------------------------------------------------------------------- #
class TestDetectKind:
    @pytest.mark.parametrize(
        "command,expected",
        [
            ("Excel.Open", KIND_ACTION),
            ("Message Box", KIND_ACTION),
            ("try", KIND_TRY),
            ("Try_Catch", KIND_TRY),
            ("ErrorHandling", KIND_TRY),
            ("catch", KIND_CATCH),
            ("CatchException", KIND_CATCH),
            ("Loop.Times", KIND_LOOP),
            ("ForEach", KIND_LOOP),
            ("While", KIND_LOOP),
            ("If", KIND_CONDITION),
            ("IfCondition", KIND_CONDITION),
            ("Else", KIND_CONDITION),
        ],
    )
    def test_classification(self, command, expected):
        assert detect_kind(command) == expected

    def test_none_and_empty(self):
        assert detect_kind(None) == KIND_UNKNOWN
        assert detect_kind("") == KIND_UNKNOWN

    @pytest.mark.parametrize(
        "command",
        [
            "Registry: Get value",  # contains "try"
            "Retry",
            "Entry point",
            "Country lookup",
            "Industry code",
            "Perform action",  # contains "for"
            "Format date",  # contains "for"
            "Modify file",  # contains "if"
            "Notify user",  # contains "if"
            "Verify checksum",  # contains "if"
            "Reach out",  # contains "each"
            "Whatever",  # contains "what"/"ever" — not a marker, must stay action
        ],
    )
    def test_marker_substrings_inside_ordinary_words_are_not_matched(self, command):
        # A false "try" makes has_error_handler() claim a handler that does not
        # exist, which silently suppresses the instrumentation prescription —
        # the one output CLAUDE.md requires for an un-instrumented bot.
        assert detect_kind(command) == KIND_ACTION


# --------------------------------------------------------------------------- #
# walk_actions
# --------------------------------------------------------------------------- #
class TestWalkActions:
    def test_flat_commands_list(self):
        tree = [
            {"command": "excel.open", "name": "Open", "lineNumber": 1},
            {"command": "messagebox", "name": "Show", "lineNumber": 2},
        ]
        nodes = walk_actions(tree)
        assert len(nodes) == 2
        assert nodes[0].command == "excel.open"
        assert nodes[0].name == "Open"
        assert nodes[0].line_number == 1
        assert nodes[0].order == 0
        assert nodes[0].path == (0,)
        assert nodes[0].depth == 0
        assert nodes[1].path == (1,)

    def test_wrapper_dict_with_commands_key(self):
        tree = {"commands": [{"commandName": "x"}]}
        nodes = walk_actions(tree)
        assert len(nodes) == 1
        assert nodes[0].command == "x"

    def test_wrapper_dict_with_children_key(self):
        tree = {"children": [{"type": "y"}]}
        nodes = walk_actions(tree)
        assert len(nodes) == 1
        assert nodes[0].command == "y"

    def test_nested_paths_and_depth(self):
        tree = {
            "commands": [
                {
                    "command": "loop.times",
                    "children": [
                        {"command": "a", "lineNumber": 5},
                        {"command": "b", "lineNumber": 6},
                    ],
                },
                {"command": "c"},
            ]
        }
        nodes = walk_actions(tree)
        # loop, a, b, c in DFS order
        assert [n.command for n in nodes] == ["loop.times", "a", "b", "c"]
        assert nodes[0].path == (0,) and nodes[0].depth == 0
        assert nodes[1].path == (0, 0) and nodes[1].depth == 1
        assert nodes[2].path == (0, 1) and nodes[2].depth == 1
        assert nodes[3].path == (1,) and nodes[3].depth == 0
        # orders are flat and sequential
        assert [n.order for n in nodes] == [0, 1, 2, 3]

    def test_permissive_command_keys(self):
        # First non-empty command-family key wins.
        nodes = walk_actions([{"commandId": "by_id"}])
        assert nodes[0].command == "by_id"

    def test_missing_identity_still_yields_node(self):
        # A node with no recognizable fields keeps its position.
        nodes = walk_actions([{}, {"command": "x"}])
        assert len(nodes) == 2
        assert nodes[0].command is None
        assert nodes[0].kind == KIND_UNKNOWN
        assert nodes[1].command == "x"

    def test_line_number_string_digit_parsed(self):
        nodes = walk_actions([{"command": "x", "line": "42"}])
        assert nodes[0].line_number == 42

    def test_zero_and_negative_line_rejected(self):
        nodes = walk_actions([{"command": "x", "lineNumber": 0}])
        assert nodes[0].line_number is None

    def test_attributes_extracted(self):
        nodes = walk_actions([{"command": "x", "attributes": {"path": "/tmp", "mode": "r"}}])
        assert nodes[0].attributes == {"path": "/tmp", "mode": "r"}

    def test_alternate_attribute_keys(self):
        nodes = walk_actions([{"command": "x", "parameters": {"a": 1}}])
        assert nodes[0].attributes == {"a": 1}

    def test_has_children_flag(self):
        tree = {"commands": [{"command": "loop", "children": [{"command": "a"}]}]}
        nodes = walk_actions(tree)
        assert nodes[0].has_children is True
        assert nodes[1].has_children is False

    def test_empty_and_invalid_inputs(self):
        assert walk_actions(None) == []
        assert walk_actions({}) == []
        assert walk_actions([]) == []
        assert walk_actions("string") == []
        assert walk_actions(42) == []

    def test_single_command_node_no_wrapper(self):
        # A bare command dict (no child array) is recognized as one node.
        nodes = walk_actions({"command": "solo"})
        assert len(nodes) == 1
        assert nodes[0].command == "solo"

    def test_non_dict_children_skipped(self):
        # Non-dict entries in a child array are ignored, not crashed on.
        tree = {"commands": [{"command": "a"}, "not-a-dict", 42, {"command": "b"}]}
        nodes = walk_actions(tree)
        assert [n.command for n in nodes] == ["a", "b"]

    def test_deep_nesting_no_overflow(self):
        # Build a left-nested tree 500 deep; iterative DFS must not overflow.
        node: dict = {"command": "leaf"}
        for _ in range(500):
            node = {"command": "wrap", "children": [node]}
        nodes = walk_actions(node)
        assert len(nodes) == 501


# --------------------------------------------------------------------------- #
# has_error_handler / find_node_* / context_window / node_label
# --------------------------------------------------------------------------- #
class TestErrorHandlerAndContext:
    def test_has_error_handler_true(self):
        nodes = walk_actions([{"command": "try"}, {"command": "a"}])
        assert has_error_handler(nodes) is True

    def test_has_error_handler_false(self):
        nodes = walk_actions([{"command": "excel.open"}, {"command": "a"}])
        assert has_error_handler(nodes) is False

    def test_find_node_by_line(self):
        nodes = walk_actions(
            [{"command": "a", "lineNumber": 1}, {"command": "b", "lineNumber": 37}]
        )
        found = find_node_by_line(nodes, 37)
        assert found is not None
        assert found.command == "b"

    def test_find_node_by_line_none_input(self):
        nodes = walk_actions([{"command": "a", "lineNumber": 1}])
        assert find_node_by_line(nodes, None) is None
        assert find_node_by_line(nodes, 0) is None

    def test_find_node_by_line_no_match(self):
        nodes = walk_actions([{"command": "a"}])
        assert find_node_by_line(nodes, 99) is None

    def test_find_node_by_order(self):
        nodes = walk_actions([{"command": "a"}, {"command": "b"}])
        assert find_node_by_order(nodes, 1).command == "b"
        assert find_node_by_order(nodes, 5) is None
        assert find_node_by_order(nodes, -1) is None
        assert find_node_by_order(nodes, None) is None

    def test_context_window_middle(self):
        nodes = walk_actions([{"command": f"c{i}"} for i in range(10)])
        target = nodes[5]
        ctx = context_window(nodes, target, before=2, after=2)
        assert [n.command for n in ctx["before"]] == ["c3", "c4"]
        assert [n.command for n in ctx["after"]] == ["c6", "c7"]

    def test_context_window_at_start(self):
        nodes = walk_actions([{"command": "a"}, {"command": "b"}, {"command": "c"}])
        ctx = context_window(nodes, nodes[0])
        assert ctx["before"] == []
        assert [n.command for n in ctx["after"]] == ["b", "c"]

    def test_context_window_at_end(self):
        nodes = walk_actions([{"command": "a"}, {"command": "b"}, {"command": "c"}])
        ctx = context_window(nodes, nodes[2])
        assert [n.command for n in ctx["before"]] == ["a", "b"]
        assert ctx["after"] == []


class TestNodeLabel:
    def test_uses_name_when_present(self):
        node = BotNode(
            order=0,
            path=(0,),
            command="excel.open",
            name="Open Excel",
            line_number=1,
            index=None,
            kind=KIND_ACTION,
            depth=0,
            has_children=False,
        )
        assert node_label(node) == "'Open Excel'"

    def test_falls_back_to_command(self):
        node = BotNode(
            order=0,
            path=(0,),
            command="excel.open",
            name=None,
            line_number=1,
            index=None,
            kind=KIND_ACTION,
            depth=0,
            has_children=False,
        )
        assert node_label(node) == "<excel.open>"

    def test_falls_back_to_order(self):
        node = BotNode(
            order=7,
            path=(0,),
            command=None,
            name=None,
            line_number=None,
            index=None,
            kind=KIND_UNKNOWN,
            depth=0,
            has_children=False,
        )
        assert node_label(node) == "action #7"


# --------------------------------------------------------------------------- #
# fetch_bot_content (network)
# --------------------------------------------------------------------------- #
class TestFetchBotContent:
    def test_calls_get_record_with_correct_path(self, monkeypatch):
        captured = {}

        def fake_get_record(auth, path):
            captured["path"] = path
            return {"commands": []}

        monkeypatch.setattr(bot, "get_record", fake_get_record)
        result = bot.fetch_bot_content(MagicMock(), "file-9")
        assert result == {"commands": []}
        assert captured["path"] == "/v2/repository/files/file-9/content"

    def test_empty_file_id_rejected(self):
        with pytest.raises(ValueError):
            bot.fetch_bot_content(MagicMock(), "")

    def test_api_error_propagates(self, monkeypatch):
        def raise_api(auth, path):
            raise A360ApiError("boom", status_code=404, path=path, body="")

        monkeypatch.setattr(bot, "get_record", raise_api)
        with pytest.raises(A360ApiError):
            bot.fetch_bot_content(MagicMock(), "file-9")

    def test_auth_error_propagates(self, monkeypatch):
        def raise_auth(auth, path):
            raise A360AuthError("nope")

        monkeypatch.setattr(bot, "get_record", raise_auth)
        with pytest.raises(A360AuthError):
            bot.fetch_bot_content(MagicMock(), "file-9")
