"""Tests for ``a360_mcp.services.diagnose`` — the core diagnosis flow.

Mocks the service-layer fetchers (``fetch_execution`` / ``fetch_bot_content``)
so these tests exercise the orchestration logic, not HTTP mechanics.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from a360_mcp.auth.auth_manager import A360AuthError
from a360_mcp.services import diagnose as diag
from a360_mcp.services.bot import BotNode
from a360_mcp.services.diagnose import (
    PRESCRIPTION_NO_ERROR_HANDLER,
    SnapshotStore,
    build_verdict,
    detect_missing_upstream,
    diagnose,
)
from a360_mcp.services.execution import ExecutionSummary
from a360_mcp.tools.a360_api import A360ApiError


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _node(order, command=None, name=None, line=None, kind="action"):
    return BotNode(
        order=order,
        path=(order,),
        command=command,
        name=name,
        line_number=line,
        index=None,
        kind=kind,
        depth=0,
        has_children=False,
    )


def _summary(
    *,
    id="exec-1",
    status="RUN_FAILED",
    message="boom",
    file_id="file-9",
    error=None,
    bot_name="MyBot",
):
    return ExecutionSummary(
        id=id,
        status=status,
        message=message,
        file_id=file_id,
        deployment_id="dep-1",
        bot_name=bot_name,
        started_on=None,
        completed_on=None,
        error=error,
        raw={},
    )


def _patch_fetches(monkeypatch, *, execution, bot_json=None, bot_error=None):
    """Patch the service boundaries used by ``diagnose``.

    Isolates orchestration: ``fetch_execution`` returns a sentinel dict and
    ``parse_execution`` returns the controlled summary, so these tests are
    about diagnosis *flow*, not execution parsing (covered separately).
    """
    monkeypatch.setattr(diag, "fetch_execution", lambda auth, eid: {"id": eid})
    monkeypatch.setattr(diag, "parse_execution", lambda raw: execution)
    if bot_error is not None:

        def raise_bot(auth, fid):
            raise bot_error

        monkeypatch.setattr(diag, "fetch_bot_content", raise_bot)
    elif bot_json is not None:
        monkeypatch.setattr(diag, "fetch_bot_content", lambda auth, fid: bot_json)
    return MagicMock()


# --------------------------------------------------------------------------- #
# build_verdict
# --------------------------------------------------------------------------- #
class TestBuildVerdict:
    def test_full_verdict_with_line_and_handler_absent(self):
        summary = _summary()
        node = _node(37, command="excel.open", name="Open Excel", line=37)
        v = build_verdict(
            execution=summary,
            failed_node=node,
            error_message="file not found",
            has_handler=False,
            missing_upstream_hint="no existence check upstream",
        )
        assert "action at line 37 'Open Excel' failed: file not found" in v
        assert "no existence check upstream" in v
        assert "no error handler around it" in v

    def test_verdict_with_handler_present_omits_handler_clause(self):
        node = _node(5, command="x", name="X", line=5)
        v = build_verdict(
            execution=_summary(),
            failed_node=node,
            error_message="oops",
            has_handler=True,
            missing_upstream_hint=None,
        )
        assert "no error handler" not in v
        assert v.endswith(".")

    def test_verdict_unknown_action(self):
        v = build_verdict(
            execution=_summary(),
            failed_node=None,
            error_message="oops",
            has_handler=True,
            missing_upstream_hint=None,
        )
        assert v.startswith("an unknown action failed")

    def test_verdict_no_line_uses_label_only(self):
        node = _node(3, command="x", name="Step")
        v = build_verdict(
            execution=_summary(),
            failed_node=node,
            error_message="oops",
            has_handler=True,
            missing_upstream_hint=None,
        )
        assert "action 'Step' failed" in v


# --------------------------------------------------------------------------- #
# detect_missing_upstream
# --------------------------------------------------------------------------- #
class TestDetectMissingUpstream:
    def test_open_without_check_flagged(self):
        nodes = [_node(0, command="excel.open")]
        assert detect_missing_upstream(nodes) == (
            "no existence check upstream of the failing action"
        )

    def test_open_with_check_not_flagged(self):
        nodes = [_node(0, command="if.fileexists"), _node(1, command="excel.open")]
        assert detect_missing_upstream(nodes) is None

    def test_empty_not_flagged(self):
        assert detect_missing_upstream([]) is None

    def test_no_open_not_flagged(self):
        nodes = [_node(0, command="messagebox")]
        assert detect_missing_upstream(nodes) is None


# --------------------------------------------------------------------------- #
# diagnose — non-failure early return
# --------------------------------------------------------------------------- #
class TestDiagnoseNonFailure:
    def test_success_status_returns_early(self, monkeypatch):
        auth = _patch_fetches(monkeypatch, execution=_summary(status="RUN_SUCCESS", message="ok"))
        result = diagnose(auth, "exec-1")
        assert "not a failure" in result["verdict"]
        assert result["failure"] is None
        assert result["failed_action"] is None

    def test_empty_status(self, monkeypatch):
        auth = _patch_fetches(monkeypatch, execution=_summary(status="", message=""))
        result = diagnose(auth, "exec-1")
        assert "no status" in result["verdict"]


# --------------------------------------------------------------------------- #
# diagnose — failure with action mapping
# --------------------------------------------------------------------------- #
class TestDiagnoseFailure:
    def test_maps_failure_to_node_by_line_number(self, monkeypatch):
        bot_json = {
            "commands": [
                {"command": "excel.open", "name": "Open", "lineNumber": 1},
                {"command": "excel.read", "name": "Read", "lineNumber": 37},
                {"command": "messagebox", "name": "Show", "lineNumber": 38},
            ]
        }
        execution = _summary(
            message="Line number: 37 file not found",
            error={"message": "file not found", "lineNumber": 37, "source": "error"},
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)

        result = diagnose(auth, "exec-1")
        assert result["status"] == "RUN_FAILED"
        assert result["failure"]["line_number"] == 37
        assert result["failure"]["source"] == "error"
        assert result["failed_action"]["line_number"] == 37
        assert result["failed_action"]["name"] == "Read"
        assert result["action_count"] == 3
        # No try/catch in this bot → prescription emitted.
        assert result["has_error_handler"] is False
        assert result["prescription"] == PRESCRIPTION_NO_ERROR_HANDLER
        # Verdict references the failing action.
        assert "line 37" in result["verdict"]
        assert "'Read'" in result["verdict"]

    def test_error_handler_suppresses_prescription(self, monkeypatch):
        bot_json = {
            "commands": [
                {
                    "command": "try",
                    "children": [
                        {"command": "excel.open", "lineNumber": 5},
                    ],
                },
            ]
        }
        execution = _summary(
            message="Line number: 5 boom",
            error={"lineNumber": 5, "message": "boom"},
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)
        result = diagnose(auth, "exec-1")
        assert result["has_error_handler"] is True
        assert result["prescription"] is None

    def test_upstream_and_downstream_context_populated(self, monkeypatch):
        bot_json = {
            "commands": [
                {"command": "a", "lineNumber": 1},
                {"command": "b", "lineNumber": 2},
                {"command": "c", "lineNumber": 3},
                {"command": "excel.open", "lineNumber": 4},
                {"command": "e", "lineNumber": 5},
                {"command": "f", "lineNumber": 6},
            ]
        }
        execution = _summary(
            message="Line number: 4 boom",
            error={"lineNumber": 4, "message": "boom"},
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)
        result = diagnose(auth, "exec-1")
        # failed action is order 3 (0-indexed)
        assert result["failed_action"]["order"] == 3
        before_cmds = [n["command"] for n in result["upstream_context"]]
        after_cmds = [n["command"] for n in result["downstream_context"]]
        assert before_cmds == ["a", "b", "c"]
        assert after_cmds == ["e", "f"]

    def test_no_file_id_diagnoses_without_mapping(self, monkeypatch):
        execution = _summary(file_id=None, message="something broke", error=None)
        monkeypatch.setattr(diag, "fetch_execution", lambda auth, eid: {"id": eid})
        monkeypatch.setattr(diag, "parse_execution", lambda raw: execution)
        bot_called = MagicMock()
        monkeypatch.setattr(diag, "fetch_bot_content", bot_called)
        result = diagnose(MagicMock(), "exec-1")
        assert result["failed_action"] is None
        # None, not 0: the bot was never read, so there is no action count.
        assert result["action_count"] is None
        assert "an unknown action" in result["verdict"]
        # bot fetch not attempted without a file id
        bot_called.assert_not_called()

    def test_bot_fetch_failure_is_not_fatal(self, monkeypatch):
        execution = _summary(
            message="Line number: 5 boom",
            error={"lineNumber": 5, "message": "boom"},
        )
        auth = _patch_fetches(
            monkeypatch,
            execution=execution,
            bot_error=A360ApiError("404", status_code=404, path="/x", body=""),
        )
        result = diagnose(auth, "exec-1")
        assert result["failed_action"] is None
        # None, not 0: the fetch failed, so the bot's action count is unknown.
        assert result["action_count"] is None
        # still a verdict about the failure
        assert "failed" in result["verdict"]


# --------------------------------------------------------------------------- #
# diagnose — error propagation
# --------------------------------------------------------------------------- #
class TestDiagnosePropagation:
    def test_auth_error_propagates(self, monkeypatch):
        def raise_auth(auth, eid):
            raise A360AuthError("nope")

        monkeypatch.setattr(diag, "fetch_execution", raise_auth)
        with pytest.raises(A360AuthError):
            diagnose(MagicMock(), "exec-1")

    def test_api_error_propagates(self, monkeypatch):
        def raise_api(auth, eid):
            raise A360ApiError("boom", status_code=500, path="/x", body="")

        monkeypatch.setattr(diag, "fetch_execution", raise_api)
        with pytest.raises(A360ApiError):
            diagnose(MagicMock(), "exec-1")


# --------------------------------------------------------------------------- #
# diagnose — snapshot hook
# --------------------------------------------------------------------------- #
class TestDiagnoseSnapshotHook:
    def test_snapshot_store_called_when_provided(self, monkeypatch):
        bot_json = {"commands": [{"command": "x", "lineNumber": 1}]}
        execution = _summary(
            message="Line number: 1 boom", error={"lineNumber": 1, "message": "boom"}
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)

        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = None  # cache miss → full flow
        store.save_diagnosis.return_value = "/state/snap.json"
        result = diagnose(auth, "exec-1", snapshot_store=store)
        assert result["snapshot_path"] == "/state/snap.json"
        store.save_diagnosis.assert_called_once()

    def test_snapshot_failure_does_not_discard_diagnosis(self, monkeypatch):
        bot_json = {"commands": [{"command": "x", "lineNumber": 1}]}
        execution = _summary(
            message="Line number: 1 boom", error={"lineNumber": 1, "message": "boom"}
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)

        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = None  # cache miss → full flow
        store.save_diagnosis.side_effect = RuntimeError("disk full")
        result = diagnose(auth, "exec-1", snapshot_store=store)
        # diagnosis still returned; snapshot path stays None
        assert result["snapshot_path"] is None
        assert result["failed_action"] is not None

    def test_no_snapshot_store_means_no_call(self, monkeypatch):
        bot_json = {"commands": [{"command": "x", "lineNumber": 1}]}
        execution = _summary(
            message="Line number: 1 boom", error={"lineNumber": 1, "message": "boom"}
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)
        result = diagnose(auth, "exec-1", snapshot_store=None)
        assert result["snapshot_path"] is None

    def test_cache_hit_skips_control_room(self, monkeypatch):
        """When the snapshot store has a cached diagnosis, CR is never hit."""
        cached = {
            "verdict": "cached verdict",
            "execution_id": "exec-1",
            "from_snapshot": True,
            "snapshot_path": "/state/snap.json",
        }
        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = cached

        # If CR were called, this would raise.
        def fail_if_called(auth, eid):
            raise AssertionError("CR fetch_execution should not be called on cache hit")

        monkeypatch.setattr(diag, "fetch_execution", fail_if_called)

        result = diagnose(MagicMock(), "exec-1", snapshot_store=store)
        assert result is cached
        store.get_diagnosis.assert_called_once_with("exec-1")
        store.save_diagnosis.assert_not_called()

    def test_cache_miss_proceeds_to_full_flow(self, monkeypatch):
        """When the store returns None, the full diagnosis flow runs and saves."""
        bot_json = {"commands": [{"command": "x", "lineNumber": 1}]}
        execution = _summary(
            message="Line number: 1 boom", error={"lineNumber": 1, "message": "boom"}
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)

        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = None  # cache miss
        store.save_diagnosis.return_value = "/state/snap.json"

        result = diagnose(auth, "exec-1", snapshot_store=store)
        store.get_diagnosis.assert_called_once_with("exec-1")
        store.save_diagnosis.assert_called_once()
        assert result["snapshot_path"] == "/state/snap.json"


# --------------------------------------------------------------------------- #
# diagnose — "the bot was never read" must not read as "the bot has no handler"
# --------------------------------------------------------------------------- #
class TestUnreadBotIsNotAnAnswer:
    """An empty node list means one of two very different things.

    Either the bot genuinely has no Try/Catch (prescribe instrumentation), or
    the bot logic was never fetched (say so — the user may already have a
    perfectly instrumented bot and a missing repository permission).
    """

    def test_bot_fetch_failure_does_not_claim_a_missing_handler(self, monkeypatch):
        execution = _summary(
            message="Line number: 5 boom", error={"lineNumber": 5, "message": "boom"}
        )
        auth = _patch_fetches(
            monkeypatch,
            execution=execution,
            bot_error=A360ApiError("403", status_code=403, path="/x", body=""),
        )
        result = diagnose(auth, "exec-1")
        assert result["bot_inspected"] is False
        assert result["has_error_handler"] is None
        assert result["action_count"] is None
        assert result["prescription"] != PRESCRIPTION_NO_ERROR_HANDLER
        assert "no error handler around it" not in result["verdict"]

    def test_bot_fetch_failure_reports_the_real_cause(self, monkeypatch):
        execution = _summary(
            message="Line number: 5 boom", error={"lineNumber": 5, "message": "boom"}
        )
        auth = _patch_fetches(
            monkeypatch,
            execution=execution,
            bot_error=A360ApiError("403", status_code=403, path="/x", body=""),
        )
        result = diagnose(auth, "exec-1")
        # The next action must be stated, not left to the reader.
        assert result["bot_read_error"]
        assert "403" in result["bot_read_error"]
        assert result["prescription"]

    def test_missing_file_id_does_not_claim_a_missing_handler(self, monkeypatch):
        execution = _summary(file_id=None, message="something broke", error=None)
        monkeypatch.setattr(diag, "fetch_execution", lambda auth, eid: {"id": eid})
        monkeypatch.setattr(diag, "parse_execution", lambda raw: execution)
        result = diagnose(MagicMock(), "exec-1")
        assert result["bot_inspected"] is False
        assert result["has_error_handler"] is None
        assert "no error handler around it" not in result["verdict"]

    def test_empty_bot_that_was_actually_read_still_prescribes(self, monkeypatch):
        # A bot we *did* read and that genuinely has no handler keeps the
        # prescription — this is the case CLAUDE.md §3.5 exists for.
        execution = _summary(message="boom", error=None)
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json={"commands": []})
        result = diagnose(auth, "exec-1")
        assert result["bot_inspected"] is True
        assert result["has_error_handler"] is False
        assert result["action_count"] == 0
        assert result["prescription"] == PRESCRIPTION_NO_ERROR_HANDLER

    def test_auth_error_during_bot_fetch_propagates(self, monkeypatch):
        # A360AuthError subclasses RuntimeError; the bare `except Exception`
        # used to turn "your session died" into a confident wrong diagnosis.
        execution = _summary(message="boom", error=None)
        auth = _patch_fetches(
            monkeypatch, execution=execution, bot_error=A360AuthError("session evicted")
        )
        with pytest.raises(A360AuthError):
            diagnose(auth, "exec-1")


# --------------------------------------------------------------------------- #
# diagnose — a degraded diagnosis must not become the permanent answer
# --------------------------------------------------------------------------- #
class TestDegradedDiagnosisIsNotCached:
    def test_degraded_diagnosis_is_not_snapshotted(self, monkeypatch):
        execution = _summary(message="boom", error=None)
        auth = _patch_fetches(
            monkeypatch,
            execution=execution,
            bot_error=A360ApiError("502", status_code=502, path="/x", body=""),
        )
        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = None
        diagnose(auth, "exec-1", snapshot_store=store)
        store.save_diagnosis.assert_not_called()

    def test_refresh_bypasses_the_cached_diagnosis(self, monkeypatch):
        bot_json = {"commands": [{"command": "x", "lineNumber": 1}]}
        execution = _summary(
            message="Line number: 1 boom", error={"lineNumber": 1, "message": "boom"}
        )
        auth = _patch_fetches(monkeypatch, execution=execution, bot_json=bot_json)

        store = MagicMock(spec=SnapshotStore)
        store.get_diagnosis.return_value = {"verdict": "stale degraded verdict"}
        store.save_diagnosis.return_value = "/state/snap.json"

        result = diagnose(auth, "exec-1", snapshot_store=store, refresh=True)
        store.get_diagnosis.assert_not_called()
        assert result["verdict"] != "stale degraded verdict"
        assert result["bot_inspected"] is True
        store.save_diagnosis.assert_called_once()
