"""Integration tests for exploration session lifecycle + platform guardrails (v0.9 票据 01).

Uses ``httpx.AsyncClient`` with ``app=`` to drive the FastAPI app in-process.
Channel I/O is stubbed with MockChannel; tests cover the full lifecycle
(create → message → close), the three budget guardrails (409), trigger-source
gating per line tier, and experience-record normalization.
"""

from datetime import timedelta

import pytest
from agenteval.models import Campaign, ChannelType, EvalTarget, PlatformType, TargetStatus
from agenteval.storage.db import ExplorationSessionDB, utc_now
from agenteval.storage.repository import CampaignRepository, ExplorationSessionRepository, TargetRepository
from agenteval.web.app import app
from httpx import ASGITransport, AsyncClient


def _make_campaign(campaign_id: str, *, time_scale: float = 1.0, status: str = "running") -> Campaign:
    return Campaign(
        id=campaign_id,
        name=f"campaign-{campaign_id}",
        target_id="t-1",
        window_seconds=86400,
        time_scale=time_scale,
        plan=[{"scenario_id": "s-1", "offset_seconds": 0, "count": 1}],
        status=status,
        started_at=utc_now(),
    )


@pytest.fixture()
def seeded_db(db_session, monkeypatch):
    """Patch get_session/get_db to the test session and seed target + campaign."""
    from agenteval.storage import db as db_module
    from agenteval.storage import repository as repo_module
    from agenteval.web import app as app_module

    monkeypatch.setattr(app_module, "init_db", lambda: None)

    def _test_get_session():
        return db_session

    monkeypatch.setattr(db_module, "get_session", _test_get_session)
    monkeypatch.setattr(repo_module, "get_session", _test_get_session)

    from agenteval.web.deps import get_db

    def _test_get_db():
        try:
            yield db_session
        finally:
            pass

    app.dependency_overrides[get_db] = _test_get_db

    # 隔离后台 judge 复核任务：API 测试不真正派发后台任务
    from agenteval.exploration import lifecycle as exploration_lifecycle

    monkeypatch.setattr(exploration_lifecycle, "start_judge_review", lambda session_id: None)

    target = EvalTarget(
        id="t-1",
        name="mock-target",
        platform=PlatformType.AI_DIGITAL_EMPLOYEE,
        channel_type=ChannelType.TUTU_API,
        channel_config={"base_url": "http://mock", "token": "x"},
        status=TargetStatus.ACTIVE,
    )
    TargetRepository(db_session).create(target)
    CampaignRepository(db_session).create(_make_campaign("c-1"))

    yield db_session
    app.dependency_overrides.clear()


def _stub_channel_factory(monkeypatch, channel) -> None:
    """Point the exploration lifecycle's ChannelFactory at a test channel."""
    from agenteval.exploration import lifecycle as lifecycle_module

    class _StubFactory:
        @staticmethod
        def create(target):
            return channel

    monkeypatch.setattr(lifecycle_module, "ChannelFactory", _StubFactory)


@pytest.fixture()
def mock_channel(monkeypatch):
    """Stub ChannelFactory in the exploration router with a MockChannel."""
    from tests.unit.mock_channel import MockChannel

    channel = MockChannel(reply_text="您好，请问有什么可以帮您？")
    _stub_channel_factory(monkeypatch, channel)
    return channel


@pytest.fixture()
async def client():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


def _session_payload(**overrides) -> dict:
    payload = {
        "campaign_id": "c-1",
        "persona": {"name": "急性子用户", "traits": ["急躁", "目标导向"]},
        "goal": "查询本月账单并完成缴费",
        "triggered_by": "auto",
    }
    payload.update(overrides)
    return payload


def _rewind_latest_session(db_session, minutes: int = 31) -> None:
    """Move the newest session's created_at back so the interval guardrail passes."""
    repo = ExplorationSessionRepository(db_session)
    sessions = repo.list_by_campaign("c-1")
    latest = max(sessions, key=lambda s: s.created_at)
    row = db_session.get(ExplorationSessionDB, latest.id)
    row.created_at = utc_now() - timedelta(minutes=minutes)
    db_session.add(row)
    db_session.commit()


async def _create_session(client, **overrides):
    return await client.post("/api/exploration/sessions", json=_session_payload(**overrides))


# ---------------------------------------------------------------- lifecycle


async def test_full_lifecycle_create_message_close(seeded_db, mock_channel, client):
    resp = await _create_session(client)
    assert resp.status_code == 200, resp.text
    session = resp.json()
    assert session["status"] == "running"
    assert session["campaign_id"] == "c-1"
    assert session["target_id"] == "t-1"
    assert session["persona"]["name"] == "急性子用户"
    session_id = session["id"]

    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "我要查这个月的账单"},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["reply"] == "您好，请问有什么可以帮您？"
    assert isinstance(body["latency_ms"], int)
    assert body["turn_count"] == 1

    repo = ExplorationSessionRepository(seeded_db)
    assert repo.get(session_id).turn_count == 1

    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/close",
        json={
            "experience": {
                "goal_achieved": True,
                "blockers": [],
                "misled": [],
                "emotion": "positive",
                "notes": "顺利完成",
            }
        },
    )
    assert resp.status_code == 200, resp.text
    closed = resp.json()
    assert closed["status"] == "completed"
    assert closed["experience"]["goal_achieved"] is True
    assert closed["experience"]["emotion"] == "positive"
    assert closed["closed_at"] is not None


async def test_dict_reply_content_is_flattened_to_text(seeded_db, monkeypatch, client):
    """通道回复 content 为对象（如 tutu msgBody）时应提取文本而非存 str(dict)。"""
    from agenteval.channels.base import Reply

    from tests.unit.mock_channel import MockChannel

    class _DictReplyChannel(MockChannel):
        async def _poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0):
            return Reply(
                question_msg_id=question_msg_id,
                content={"content": "您好，我是客服"},
                raw_message={},
            )

    _stub_channel_factory(monkeypatch, _DictReplyChannel(reply_text="unused"))

    resp = await _create_session(client)
    assert resp.status_code == 200, resp.text
    session_id = resp.json()["id"]

    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "你好"},
    )
    assert resp.status_code == 200, resp.text
    assert resp.json()["reply"] == "您好，我是客服"

    messages_resp = await client.get(f"/api/exploration/sessions/{session_id}/messages")
    assistant = [m for m in messages_resp.json()["messages"] if m["role"] == "assistant"]
    assert assistant[0]["content"] == "您好，我是客服"


async def test_create_requires_existing_running_campaign(seeded_db, client):
    resp = await _create_session(client, campaign_id="nope")
    assert resp.status_code == 404

    CampaignRepository(seeded_db).create(_make_campaign("c-done", status="completed"))
    resp = await _create_session(client, campaign_id="c-done")
    assert resp.status_code == 409
    assert "进行中" in resp.json()["detail"]


async def test_accelerated_line_accepts_manual_only(seeded_db, mock_channel, client):
    CampaignRepository(seeded_db).create(_make_campaign("c-fast", time_scale=24.0))

    resp = await _create_session(client, campaign_id="c-fast", triggered_by="auto")
    assert resp.status_code == 409
    assert "手动" in resp.json()["detail"]

    resp = await _create_session(client, campaign_id="c-fast", triggered_by="manual")
    assert resp.status_code == 200


async def test_session_budget_guardrail(seeded_db, mock_channel, client):
    for _ in range(8):
        resp = await _create_session(client)
        assert resp.status_code == 200, resp.text
        _rewind_latest_session(seeded_db)

    resp = await _create_session(client)
    assert resp.status_code == 409
    assert "预算" in resp.json()["detail"]


async def test_session_interval_guardrail(seeded_db, mock_channel, client):
    resp = await _create_session(client)
    assert resp.status_code == 200

    resp = await _create_session(client)
    assert resp.status_code == 409
    assert "间隔" in resp.json()["detail"]

    _rewind_latest_session(seeded_db)
    resp = await _create_session(client)
    assert resp.status_code == 200


async def test_campaign_budget_override_limits_sessions(seeded_db, mock_channel, client):
    """活动级预算覆盖生效（票据 02）：max_sessions=1 时第二个会话被拒。"""
    campaign = CampaignRepository(seeded_db).get("c-1")
    campaign.exploration_budget = {"max_sessions": 1}
    CampaignRepository(seeded_db).update(campaign)

    assert (await _create_session(client)).status_code == 200
    _rewind_latest_session(seeded_db)
    resp = await _create_session(client)
    assert resp.status_code == 409
    assert "预算" in resp.json()["detail"]


async def test_turn_budget_guardrail(seeded_db, mock_channel, client):
    session_id = (await _create_session(client)).json()["id"]
    for i in range(12):
        resp = await client.post(
            f"/api/exploration/sessions/{session_id}/messages",
            json={"content": f"第 {i + 1} 轮问题"},
        )
        assert resp.status_code == 200, resp.text

    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "第 13 轮问题"},
    )
    assert resp.status_code == 409
    assert "轮数" in resp.json()["detail"]


async def test_message_rejected_when_session_not_running(seeded_db, mock_channel, client):
    session_id = (await _create_session(client)).json()["id"]
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/close",
        json={"experience": {"goal_achieved": False}},
    )
    assert resp.status_code == 200

    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "还在吗？"},
    )
    assert resp.status_code == 409
    assert "进行中" in resp.json()["detail"]


async def test_message_unknown_session_returns_404(seeded_db, mock_channel, client):
    resp = await client.post("/api/exploration/sessions/nope/messages", json={"content": "hi"})
    assert resp.status_code == 404


async def test_channel_failure_returns_502_without_consuming_turn(seeded_db, monkeypatch, client):
    from tests.unit.mock_channel import MockChannel

    channel = MockChannel(send_ok=False)
    _stub_channel_factory(monkeypatch, channel)

    session_id = (await _create_session(client)).json()["id"]
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "你好"},
    )
    assert resp.status_code == 502
    assert ExplorationSessionRepository(seeded_db).get(session_id).turn_count == 0


async def test_poll_timeout_consumes_turn_budget(seeded_db, monkeypatch, client):
    """消息已送达但等不到回复：账本仍计一轮（超时不可绕过轮数预算）。"""
    from types import SimpleNamespace

    from agenteval.exploration import lifecycle as exploration_module

    from tests.unit.mock_channel import MockChannel

    channel = MockChannel(missing_reply=True)
    _stub_channel_factory(monkeypatch, channel)
    monkeypatch.setattr(exploration_module, "get_settings", lambda: SimpleNamespace(poll_reply_timeout=0.05))

    session_id = (await _create_session(client)).json()["id"]
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/messages",
        json={"content": "有人在吗"},
    )
    assert resp.status_code == 502
    assert ExplorationSessionRepository(seeded_db).get(session_id).turn_count == 1


async def test_close_normalizes_experience(seeded_db, mock_channel, client):
    session_id = (await _create_session(client)).json()["id"]
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/close",
        json={
            "experience": {
                "blockers": [42, {"not": "a string"}],
                "misled": "不是列表",
                "emotion": "暴怒!!!",
            }
        },
    )
    assert resp.status_code == 200, resp.text
    experience = resp.json()["experience"]
    assert experience["goal_achieved"] is False
    assert experience["blockers"] == ["42"]
    assert experience["misled"] == []
    assert experience["emotion"] == "neutral"


async def test_close_twice_rejected(seeded_db, mock_channel, client):
    session_id = (await _create_session(client)).json()["id"]
    payload = {"experience": {"goal_achieved": True}}
    assert (await client.post(f"/api/exploration/sessions/{session_id}/close", json=payload)).status_code == 200
    resp = await client.post(f"/api/exploration/sessions/{session_id}/close", json=payload)
    assert resp.status_code == 409


async def test_close_triggers_judge_review(seeded_db, mock_channel, client, monkeypatch):
    from agenteval.exploration import lifecycle as exploration_lifecycle

    started: list[str] = []
    monkeypatch.setattr(exploration_lifecycle, "start_judge_review", started.append)

    session_id = (await _create_session(client)).json()["id"]
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/close", json={"experience": {"goal_achieved": True}}
    )
    assert resp.status_code == 200, resp.text
    assert started == [session_id]


# ---------------------------------------------------------------- read endpoints (ticket 06)


async def test_list_campaign_sessions_returns_lifecycle_fields(seeded_db, mock_channel, client):
    resp = await _create_session(client)
    assert resp.status_code == 200, resp.text
    session_id = resp.json()["id"]

    await client.post(f"/api/exploration/sessions/{session_id}/messages", json={"content": "查账单"})
    resp = await client.post(
        f"/api/exploration/sessions/{session_id}/close",
        json={"experience": {"goal_achieved": True, "blockers": [], "misled": ["跳转误导"], "notes": "绕了三圈"}},
    )
    assert resp.status_code == 200, resp.text

    resp = await client.get("/api/exploration/campaigns/c-1/sessions")
    assert resp.status_code == 200, resp.text
    sessions = resp.json()["sessions"]
    assert len(sessions) == 1
    entry = sessions[0]
    assert entry["id"] == session_id
    assert entry["status"] == "completed"
    assert entry["turn_count"] == 1
    assert entry["goal"] == "查询本月账单并完成缴费"
    assert entry["persona"]["name"] == "急性子用户"
    assert entry["experience"]["goal_achieved"] is True
    assert entry["experience"]["misled"] == ["跳转误导"]
    assert entry["closed_at"] is not None


async def test_list_sessions_empty_campaign(seeded_db, client):
    resp = await client.get("/api/exploration/campaigns/c-1/sessions")
    assert resp.status_code == 200, resp.text
    assert resp.json() == {"sessions": []}


async def test_list_session_messages_returns_conversation(seeded_db, mock_channel, client):
    session_id = (await _create_session(client)).json()["id"]
    await client.post(f"/api/exploration/sessions/{session_id}/messages", json={"content": "我要查账单"})

    resp = await client.get(f"/api/exploration/sessions/{session_id}/messages")
    assert resp.status_code == 200, resp.text
    messages = resp.json()["messages"]
    assert len(messages) == 2
    user_msg, agent_msg = messages
    assert user_msg["role"] == "user"
    assert user_msg["content"] == "我要查账单"
    assert user_msg["round_index"] == 1
    assert agent_msg["role"] == "assistant"
    assert agent_msg["content"] == "您好，请问有什么可以帮您？"
    assert agent_msg["round_index"] == 1
    assert isinstance(agent_msg["latency_ms"], int)
    assert user_msg["created_at"] is not None


async def test_list_messages_unknown_session_returns_404(seeded_db, client):
    resp = await client.get("/api/exploration/sessions/nope/messages")
    assert resp.status_code == 404


# ---------------------------------------------------------------- migration


def test_exploration_migration_on_existing_db(tmp_path, monkeypatch):
    """Alembic migration applies on an existing DB at the previous head.

    Brand-new DBs take the create_all path (exercised by every test above via
    the db_session fixture, which creates the new tables from metadata).
    """
    from pathlib import Path

    from agenteval.storage import db as db_module
    from alembic import command
    from alembic.config import Config
    from sqlalchemy import create_engine, inspect

    database_url = f"sqlite:///{tmp_path / 'exploration.db'}"
    monkeypatch.setattr(db_module, "DATABASE_URL", database_url)
    config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))

    # 既有库先例：基线迁移假设 create_all 建好的表已存在，先 stamp 基线前状态
    from sqlmodel import SQLModel

    engine = create_engine(database_url)
    SQLModel.metadata.create_all(engine)
    engine.dispose()
    with create_engine(database_url).begin() as connection:
        from sqlalchemy import text

        connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_messages"))
        connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_sessions"))
        connection.execute(text("DROP TABLE IF EXISTS intelligent_evals"))
        connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_config_snapshots"))
        connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_decision_logs"))
        connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_task_queue"))
        connection.execute(text("DROP TABLE IF EXISTS openclaw_cron_pool"))
        connection.execute(text("DROP TABLE IF EXISTS cron_pool_alert_history"))
        connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
        connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
        connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
        connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_budget"))
        connection.execute(text("ALTER TABLE campaigns DROP COLUMN last_patrolled_at"))
        connection.execute(text("DROP TABLE IF EXISTS alembic_version"))

    command.stamp(config, "f2a9b7c34d18")
    command.upgrade(config, "head")

    inspector = inspect(create_engine(database_url))
    tables = set(inspector.get_table_names())
    assert "exploration_sessions" in tables
    assert "exploration_messages" in tables
    session_cols = {c["name"] for c in inspector.get_columns("exploration_sessions")}
    assert {
        "campaign_id",
        "target_id",
        "persona",
        "goal",
        "seed_ref",
        "status",
        "triggered_by",
        "experience",
        "judge_review",
        "turn_count",
        "closed_at",
    } <= session_cols
    message_cols = {c["name"] for c in inspector.get_columns("exploration_messages")}
    assert {"session_id", "round_index", "role", "content", "latency_ms"} <= message_cols
