"""Tests for goal state interview, persistence, and MCP tool surface.

Covers requirements: GOAL-01, GOAL-02, GOAL-03, GOAL-04, GOAL-05, MCP-02.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal, get_args, get_origin, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from mcp_vm_blackbox.goal_state import (
    CriterionEntry,
    GoalState,
    HostEntry,
    OverwriteConfirm,
    ScenarioStep,
    TicketRefEntry,
    vm_get_goal_state,
    vm_goal_interview,
    vm_list_goal_states,
)
from mcp_vm_blackbox.goal_state_store import GoalStateStore

if TYPE_CHECKING:
    from pathlib import Path

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


def _accepted(data: object) -> MagicMock:
    """Return a mock AcceptedElicitation with .data set."""
    from fastmcp.server.context import AcceptedElicitation

    result = MagicMock(spec=AcceptedElicitation)
    result.data = data
    # isinstance check must pass
    result.__class__ = AcceptedElicitation
    return result


def _declined() -> MagicMock:
    """Return a mock DeclinedElicitation."""
    from fastmcp.server.context import DeclinedElicitation

    result = MagicMock(spec=DeclinedElicitation)
    result.__class__ = DeclinedElicitation
    return result


def _is_flat_elicit_field_type(field_type: object) -> bool:
    """True for primitives or string/number literal unions (no nested models)."""
    if field_type in (str, int, float, bool):
        return True
    origin = get_origin(field_type)
    if origin is Literal:
        return all(isinstance(a, (str, int, float, bool)) for a in get_args(field_type))
    return False


# ---------------------------------------------------------------------------
# GOAL-01: vm_goal_interview calls ctx.elicit at least once
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_interview_calls_elicit(mock_ctx, tmp_path: Path) -> None:
    """GOAL-01: vm_goal_interview calls ctx.elicit at least once per step."""
    store = GoalStateStore(root=tmp_path)

    scenario = ScenarioStep(scenario_name="install", fail_fast=False)
    host = HostEntry(host_name="web", host_ip="192.168.1.10", protocol="WinRM", port=5985, username="vagrant")
    criterion_port = CriterionEntry(host_name="web", criterion_type="port_open", param_key="port", param_value="80")
    criterion_svc = CriterionEntry(
        host_name="web", criterion_type="service_running", param_key="service_name", param_value="nginx"
    )

    mock_ctx.elicit = AsyncMock(
        side_effect=[
            _accepted(scenario),  # step 1
            _accepted(host),  # host 1
            _declined(),  # end hosts
            _accepted(criterion_port),  # criterion 1 for web
            _accepted(criterion_svc),  # criterion 2 for web
            _declined(),  # end criteria for web
            _declined(),  # end tickets
        ]
    )

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_goal_interview("vm1", mock_ctx)

    assert mock_ctx.elicit.call_count >= 1
    assert "committed" in result


# ---------------------------------------------------------------------------
# GOAL-01: Abort saves draft
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_interview_abort_saves_draft(mock_ctx, tmp_path: Path) -> None:
    """GOAL-01: Abandoning the interview mid-way saves a draft (status='draft')."""
    store = GoalStateStore(root=tmp_path)

    mock_ctx.elicit = AsyncMock(return_value=_declined())

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_goal_interview("vm1", mock_ctx)

    assert "aborted" in result.lower() or "draft" in result.lower()
    # Draft file must exist
    draft_files = list((tmp_path / "goal-states").rglob("*.draft.json"))
    assert draft_files, "Expected at least one draft file to be saved"


# ---------------------------------------------------------------------------
# GOAL-02: GoalState model has required fields
# ---------------------------------------------------------------------------


def test_goal_state_model_fields() -> None:
    """GOAL-02: GoalState Pydantic model has all required fields."""
    goal = GoalState(vm_name="vm1", scenario_name="install")
    assert goal.vm_name == "vm1"
    assert goal.scenario_name == "install"
    assert goal.status in ("draft", "committed")
    assert isinstance(goal.hosts, list)
    assert isinstance(goal.criteria, list)
    assert isinstance(goal.ticket_refs, list)


# ---------------------------------------------------------------------------
# GOAL-02: Mandatory field enforcement (missing criteria)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_mandatory_field_enforcement(mock_ctx, tmp_path: Path) -> None:
    """GOAL-02: Interview without both port_open and service_running criterion saves draft."""
    store = GoalStateStore(root=tmp_path)

    scenario = ScenarioStep(scenario_name="install", fail_fast=False)
    host = HostEntry(host_name="web", host_ip="192.168.1.10", protocol="WinRM", port=5985, username="vagrant")
    # Only port_open — missing service_running
    criterion_port = CriterionEntry(host_name="web", criterion_type="port_open", param_key="port", param_value="80")

    mock_ctx.elicit = AsyncMock(
        side_effect=[
            _accepted(scenario),
            _accepted(host),
            _declined(),  # end hosts
            _accepted(criterion_port),
            _declined(),  # end criteria
            _declined(),  # end tickets
        ]
    )

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_goal_interview("vm1", mock_ctx)

    assert "port check AND one service check required" in result
    # Must be draft, not committed
    committed = store.load("vm1", "install")
    assert committed is None


# ---------------------------------------------------------------------------
# GOAL-03: State persisted to ctx.set_state after completed interview
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_state_persisted_after_interview(mock_ctx, tmp_path: Path) -> None:
    """GOAL-03: After a completed interview, goal state is persisted to ctx.set_state."""
    store = GoalStateStore(root=tmp_path)

    scenario = ScenarioStep(scenario_name="install", fail_fast=True)
    host = HostEntry(host_name="db", host_ip="10.0.0.1", protocol="SSH", port=22, username="vagrant")
    criterion_port = CriterionEntry(host_name="db", criterion_type="port_open", param_key="port", param_value="5432")
    criterion_svc = CriterionEntry(
        host_name="db", criterion_type="service_running", param_key="service_name", param_value="postgresql"
    )

    mock_ctx.elicit = AsyncMock(
        side_effect=[
            _accepted(scenario),
            _accepted(host),
            _declined(),
            _accepted(criterion_port),
            _accepted(criterion_svc),
            _declined(),
            _declined(),
        ]
    )

    with patch("mcp_vm_blackbox.goal_state._store", store):
        await vm_goal_interview("vm1", mock_ctx)

    mock_ctx.set_state.assert_called_once()
    call_args = mock_ctx.set_state.call_args
    key = call_args[0][0]
    data = call_args[0][1]
    assert key == "goal:vm1/install"
    assert data["vm_name"] == "vm1"
    assert data["scenario_name"] == "install"


# ---------------------------------------------------------------------------
# GOAL-04: vm_get_goal_state — session state first, file store fallback
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_goal_state_session_then_file(mock_ctx, tmp_path: Path) -> None:
    """GOAL-04: vm_get_goal_state returns session state first, falls back to file store."""
    store = GoalStateStore(root=tmp_path)

    # Seed a committed goal in the file store.
    # Use model_validate on a plain dict to avoid xdist cross-worker class
    # identity issues that arise when conftest loads packages.mcp_vm_blackbox.server
    # (registering a second copy of mcp_vm_blackbox.models under a different path).
    committed = GoalState.model_validate({
        "vm_name": "vm1",
        "scenario_name": "install",
        "status": "committed",
        "hosts": [{"name": "web", "ip": "1.2.3.4", "protocol": "WinRM", "port": 5985, "username": "vagrant"}],
        "criteria": [{"criterion_id": "abc", "host_name": "web", "criterion_type": "port_open", "params": {}}],
    })
    store.save(committed)

    # ctx.get_state returns None → should fall back to file
    mock_ctx.get_state = AsyncMock(return_value=None)

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_get_goal_state("vm1", "install", mock_ctx)

    assert "from file" in result
    assert "vm1/install" in result

    # Now ctx.get_state returns data → should use session
    mock_ctx.get_state = AsyncMock(return_value=committed.model_dump())

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result2 = await vm_get_goal_state("vm1", "install", mock_ctx)

    assert "from session" in result2


# ---------------------------------------------------------------------------
# GOAL-05: vm_goal_interview prompts overwrite when committed state exists
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_runner_gate_auto_prompts(mock_ctx, tmp_path: Path) -> None:
    """GOAL-05: vm_goal_interview prompts overwrite when committed state already exists."""
    store = GoalStateStore(root=tmp_path)

    # Pre-seed a committed state
    existing = GoalState(vm_name="vm1", scenario_name="install", status="committed")
    store.save(existing)

    scenario = ScenarioStep(scenario_name="install", fail_fast=False)
    # Decline the overwrite prompt → interview aborts
    mock_ctx.elicit = AsyncMock(
        side_effect=[
            _accepted(scenario),  # step 1
            _declined(),  # overwrite prompt — decline
        ]
    )

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_goal_interview("vm1", mock_ctx)

    assert "preserved" in result.lower()


# ---------------------------------------------------------------------------
# GOAL-05: Re-trigger — overwrite with confirm="yes" replaces committed state
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_retrigger_overwrites_state(mock_ctx, tmp_path: Path) -> None:
    """GOAL-05: Calling vm_goal_interview again with confirm=yes overwrites prior state."""
    store = GoalStateStore(root=tmp_path)

    existing = GoalState(vm_name="vm1", scenario_name="install", status="committed")
    store.save(existing)

    scenario = ScenarioStep(scenario_name="install", fail_fast=False)
    overwrite = OverwriteConfirm(confirm="yes")
    host = HostEntry(host_name="web", host_ip="192.168.1.10", protocol="WinRM", port=5985, username="vagrant")
    criterion_port = CriterionEntry(host_name="web", criterion_type="port_open", param_key="port", param_value="80")
    criterion_svc = CriterionEntry(
        host_name="web", criterion_type="service_running", param_key="service_name", param_value="nginx"
    )

    mock_ctx.elicit = AsyncMock(
        side_effect=[
            _accepted(scenario),
            _accepted(overwrite),  # confirm overwrite
            _accepted(host),
            _declined(),
            _accepted(criterion_port),
            _accepted(criterion_svc),
            _declined(),
            _declined(),
        ]
    )

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = await vm_goal_interview("vm1", mock_ctx)

    assert "committed" in result
    updated = store.load("vm1", "install")
    assert updated is not None
    assert updated.status == "committed"


# ---------------------------------------------------------------------------
# vm_list_goal_states: returns committed and draft states
# ---------------------------------------------------------------------------


def test_list_goal_states_returns_committed(tmp_path: Path) -> None:
    """vm_list_goal_states lists committed states from the store."""
    store = GoalStateStore(root=tmp_path)
    goal = GoalState(vm_name="vm1", scenario_name="install", status="committed")
    store.save(goal)

    with patch("mcp_vm_blackbox.goal_state._store", store):
        result = vm_list_goal_states()

    assert "vm1/install" in result
    assert "Committed" in result


# ---------------------------------------------------------------------------
# MCP-02: All elicitation schemas are flat (no nested objects)
# ---------------------------------------------------------------------------


def test_elicit_schemas_are_flat(mock_ctx) -> None:
    """MCP-02: All ctx.elicit() schemas used in interview are flat (no nested objects)."""
    schema_classes = [ScenarioStep, HostEntry, CriterionEntry, TicketRefEntry, OverwriteConfirm]

    for cls in schema_classes:
        hints = get_type_hints(cls)
        for field_name, field_type in hints.items():
            assert _is_flat_elicit_field_type(field_type), (
                f"{cls.__name__}.{field_name} has non-flat type {field_type!r}. "
                "All elicitation schema fields must be primitives or Literal thereof (no nested objects)."
            )
