"""M4 E2E CLI error-path tests.

Tests that the ``agents plan`` CLI subcommands handle error conditions
gracefully: read-only plans, unavailable actions, missing changesets,
and empty decision lists.

Usage (via dispatcher):
    python robot/helper_m4_e2e_verification.py cli-plan-execute-readonly
    python robot/helper_m4_e2e_verification.py cli-plan-use-not-found
    python robot/helper_m4_e2e_verification.py cli-plan-diff-no-changeset
    python robot/helper_m4_e2e_verification.py cli-plan-tree-empty
"""

from __future__ import annotations

import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

# Ensure the src directory is on the import path.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
    sys.path.append(_SRC)

from helper_m4_e2e_common import (  # noqa: E402
    _ROOT_ULID,
    _assert_exit_code,
    _fail,
    _mock_parent_plan,
)
from typer.testing import CliRunner  # noqa: E402

from cleveragents.application.services.plan_lifecycle_service import (  # noqa: E402
    ActionNotAvailableError,
)
from cleveragents.cli.commands.plan import app as plan_app  # noqa: E402
from cleveragents.core.exceptions import PlanError  # noqa: E402
from cleveragents.domain.models.core.action import ActionState  # noqa: E402
from cleveragents.domain.models.core.plan import (  # noqa: E402
    PlanPhase,
    ProcessingState,
)

_runner = CliRunner()


# ---------------------------------------------------------------------------
# cli-plan-execute-readonly (error path)
# ---------------------------------------------------------------------------


def cli_plan_execute_readonly() -> None:
    """Verify ``plan execute`` aborts on a read-only plan.

    The ``execute`` command calls ``service.get_plan(plan_id)`` as a
    fail-fast read-only guard.  When ``plan.read_only is True``, the
    CLI should print an error and abort without calling ``execute_plan``.
    """
    mock_service = MagicMock()
    plan = _mock_parent_plan(
        phase=PlanPhase.STRATEGIZE,
        state=ProcessingState.COMPLETE,
        read_only=True,
    )
    mock_service.get_plan.return_value = plan

    with (
        patch(
            "cleveragents.cli.commands.plan._get_lifecycle_service",
            return_value=mock_service,
        ),
        patch(
            "cleveragents.cli.commands.plan._get_plan_executor",
            return_value=MagicMock(),
        ),
    ):
        result = _runner.invoke(plan_app, ["execute", _ROOT_ULID])
        if result.exit_code == 0:
            _fail(
                f"plan execute should fail for read-only plan\noutput={result.output}"
            )
        output_lower = result.output.lower()
        if "read-only" not in output_lower and "read_only" not in output_lower:
            _fail(f"output should mention read-only\noutput={result.output}")

        # execute_plan must not have been called
        mock_service.execute_plan.assert_not_called()

    print("m4-cli-plan-execute-readonly-ok")


# ---------------------------------------------------------------------------
# cli-plan-use-not-found (error path)
# ---------------------------------------------------------------------------


def cli_plan_use_not_found() -> None:
    """Verify ``plan use`` aborts when the action is not available.

    Raises ``ActionNotAvailableError`` from ``get_action_by_name``,
    which the CLI catches and prints a user-friendly error message.
    """
    mock_service = MagicMock()
    mock_service.get_action_by_name.side_effect = ActionNotAvailableError(
        "nonexistent/action", ActionState.ARCHIVED
    )

    with patch(
        "cleveragents.cli.commands.plan._get_lifecycle_service",
        return_value=mock_service,
    ):
        result = _runner.invoke(
            plan_app, ["use", "nonexistent/action", "local/monorepo"]
        )
        if result.exit_code == 0:
            _fail(
                f"plan use should fail for unavailable action\noutput={result.output}"
            )
        output_lower = result.output.lower()
        # Assert command-specific message fragment to avoid accepting
        # unrelated error paths.  The production CLI prints:
        #   "[red]Action not available:[/red] {e}"
        if "action not available" not in output_lower:
            _fail(
                f"output should contain 'Action not available' "
                f"(got generic or wrong error)\noutput={result.output}"
            )

        # use_action must not have been called
        mock_service.use_action.assert_not_called()

    print("m4-cli-plan-use-not-found-ok")


# ---------------------------------------------------------------------------
# cli-plan-diff-no-changeset (error path)
# ---------------------------------------------------------------------------


def cli_plan_diff_no_changeset() -> None:
    """Verify ``plan diff`` aborts when the plan has no changeset.

    Raises ``PlanError`` from the apply service ``diff()`` method,
    which the CLI catches and prints ``[red]Diff Error:[/red]``.
    """
    mock_apply_svc = MagicMock()
    mock_apply_svc.diff.side_effect = PlanError(
        f"Plan {_ROOT_ULID} has no ChangeSet. "
        "Execute phase must complete before viewing diff."
    )

    with patch(
        "cleveragents.cli.commands.plan._get_apply_service",
        return_value=mock_apply_svc,
    ):
        result = _runner.invoke(plan_app, ["diff", _ROOT_ULID])
        if result.exit_code == 0:
            _fail(
                f"plan diff should fail when no changeset exists\n"
                f"output={result.output}"
            )
        output_lower = result.output.lower()
        # Assert command-specific message fragment to avoid accepting
        # unrelated error paths.  The production CLI prints:
        #   "[red]Diff Error:[/red] {e.message}"
        # where the PlanError message contains "has no ChangeSet".
        if "diff error" not in output_lower and "has no changeset" not in output_lower:
            _fail(
                f"output should contain 'Diff Error' or 'has no ChangeSet' "
                f"(got generic or wrong error)\noutput={result.output}"
            )

    print("m4-cli-plan-diff-no-changeset-ok")


# ---------------------------------------------------------------------------
# cli-plan-tree-empty (error path)
# ---------------------------------------------------------------------------


def cli_plan_tree_empty() -> None:
    """Verify ``plan tree`` handles zero decisions gracefully.

    When ``DecisionService.list_decisions()`` returns an empty list, the
    CLI should print an informational message and exit 0 (not crash).
    """
    # The production ``tree`` command calls ``container.decision_service()``
    # (not ``container.resolve()``), so the mock must match that API.
    mock_container = MagicMock()
    mock_decision_svc = MagicMock()
    mock_decision_svc.list_decisions.return_value = []
    mock_container.decision_service.return_value = mock_decision_svc

    with patch(
        "cleveragents.application.container.get_container",
        return_value=mock_container,
    ):
        result = _runner.invoke(plan_app, ["tree", _ROOT_ULID])
        _assert_exit_code(result, "plan tree (empty)")
        if "no decisions" not in result.output.lower():
            _fail(f"output should mention 'No decisions'\noutput={result.output}")

    print("m4-cli-plan-tree-empty-ok")
