"""Tests for S2 new rules: json_schema, safety, semantic_similarity,
and rule combination logic (all/any/weighted)."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from agenteval.evaluation.engine import EvalEngine
from agenteval.evaluation.rules.json_schema import JsonSchemaRule, _get_path
from agenteval.evaluation.rules.safety import SafetyRule
from agenteval.evaluation.rules.semantic import SemanticSimilarityRule, _cosine
from agenteval.models import (
    Case,
    CaseType,
    EvalRuleConfig,
    EvalTarget,
    PlatformType,
    RuleLogic,
    RunStatus,
    Scenario,
    TargetStatus,
    Turn,
)

from tests.unit.mock_channel import MockChannel

# ── helpers ──────────────────────────────────────────────────────────────

def _turn(reply_text: str, latency_ms: int = 100) -> Turn:
    return Turn(
        id="t1", run_id="r1", case_id="c1", round_index=1,
        reply={"msgBody": {"content": reply_text}},
        latency_ms=latency_ms,
    )


def _case(
    *,
    rules: list[dict] | None = None,
    rule_logic: RuleLogic = RuleLogic.ALL,
    rule_pass_threshold: float = 0.6,
) -> Case:
    eval_rules = [EvalRuleConfig(**r) for r in (rules or [])]
    return Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=eval_rules,
        rule_logic=rule_logic,
        rule_pass_threshold=rule_pass_threshold,
    )


# ── _get_path ─────────────────────────────────────────────────────────────

def test_json_schema_get_path_nested():
    assert _get_path({"a": {"b": 1}}, "a.b") == (True, 1)


def test_json_schema_get_path_missing():
    assert _get_path({"a": 1}, "a.b") == (False, None)


# ── JsonSchemaRule ────────────────────────────────────────────────────────

async def test_json_schema_valid_json_no_constraints():
    rule = JsonSchemaRule({})
    result = await rule.evaluate(_case(), [_turn('{"key": "value"}')])
    assert result.passed is True


async def test_json_schema_invalid_json_strict():
    rule = JsonSchemaRule({"strict_json": True})
    result = await rule.evaluate(_case(), [_turn("not json at all")])
    assert result.passed is False
    assert "合法 JSON" in result.reason


async def test_json_schema_invalid_json_nonstrict():
    rule = JsonSchemaRule({"strict_json": False})
    result = await rule.evaluate(_case(), [_turn("not json")])
    assert result.passed is True


async def test_json_schema_required_key_present():
    rule = JsonSchemaRule({"required_keys": ["status", "data.id"]})
    result = await rule.evaluate(_case(), [_turn('{"status": "ok", "data": {"id": 42}}')])
    assert result.passed is True


async def test_json_schema_required_key_missing():
    rule = JsonSchemaRule({"required_keys": ["missing_key"]})
    result = await rule.evaluate(_case(), [_turn('{"status": "ok"}')])
    assert result.passed is False
    assert "missing_key" in result.reason


async def test_json_schema_forbidden_key_present():
    rule = JsonSchemaRule({"forbidden_keys": ["error"]})
    result = await rule.evaluate(_case(), [_turn('{"status": "ok", "error": "oops"}')])
    assert result.passed is False
    assert "error" in result.reason


async def test_json_schema_type_check_pass():
    rule = JsonSchemaRule({"key_types": {"count": "int", "name": "str"}})
    result = await rule.evaluate(_case(), [_turn('{"count": 5, "name": "hello"}')])
    assert result.passed is True


async def test_json_schema_type_check_fail():
    rule = JsonSchemaRule({"key_types": {"count": "int"}})
    result = await rule.evaluate(_case(), [_turn('{"count": "five"}')])
    assert result.passed is False
    assert "count" in result.reason


async def test_json_schema_strips_markdown_fence():
    rule = JsonSchemaRule({"required_keys": ["id"]})
    reply = '```json\n{"id": 123}\n```'
    result = await rule.evaluate(_case(), [_turn(reply)])
    assert result.passed is True


async def test_json_schema_empty_dialog():
    rule = JsonSchemaRule({})
    result = await rule.evaluate(_case(), [])
    assert result.passed is False


# ── SafetyRule ────────────────────────────────────────────────────────────

async def test_safety_clean_reply():
    rule = SafetyRule({"blacklist": ["kill", "harm"]})
    result = await rule.evaluate(_case(), [_turn("This is a helpful response.")])
    assert result.passed is True


async def test_safety_blacklist_hit():
    rule = SafetyRule({"blacklist": ["kill"]})
    result = await rule.evaluate(_case(), [_turn("I will kill you!")])
    assert result.passed is False
    assert "kill" in result.reason


async def test_safety_blacklist_case_insensitive():
    rule = SafetyRule({"blacklist": ["HARM"]})
    result = await rule.evaluate(_case(), [_turn("this causes harm")])
    assert result.passed is False


async def test_safety_no_blacklist():
    rule = SafetyRule({})
    result = await rule.evaluate(_case(), [_turn("any text")])
    assert result.passed is True


async def test_safety_moderation_api_flagged():
    rule = SafetyRule({
        "use_moderation_api": True,
        "api_url": "http://mock/v1/moderations",
        "api_key": "test",
    })
    mock_response = MagicMock()
    mock_response.raise_for_status = MagicMock()
    mock_response.json = MagicMock(return_value={
        "results": [{"flagged": True, "categories": {"hate": True, "violence": False}}]
    })

    with patch("agenteval.evaluation.rules.safety.httpx.AsyncClient") as MockClient:
        instance = MockClient.return_value.__aenter__.return_value
        instance.post = AsyncMock(return_value=mock_response)
        result = await rule.evaluate(_case(), [_turn("hateful content here")])

    assert result.passed is False
    assert "hate" in result.reason


async def test_safety_moderation_api_unavailable_degrades():
    rule = SafetyRule({
        "use_moderation_api": True,
        "api_url": "http://unavailable/v1/moderations",
    })
    with patch("agenteval.evaluation.rules.safety.httpx.AsyncClient") as MockClient:
        instance = MockClient.return_value.__aenter__.return_value
        instance.post = AsyncMock(side_effect=Exception("connection refused"))
        result = await rule.evaluate(_case(), [_turn("normal text")])

    assert result.passed is True
    assert "降级" in result.reason


async def test_safety_empty_dialog():
    rule = SafetyRule({})
    result = await rule.evaluate(_case(), [])
    assert result.passed is False


# ── SemanticSimilarityRule ────────────────────────────────────────────────

def test_cosine_identical():
    v = [1.0, 0.0, 0.0]
    assert _cosine(v, v) == pytest.approx(1.0)


def test_cosine_orthogonal():
    assert _cosine([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0)


def test_cosine_zero_vector():
    assert _cosine([0.0, 0.0], [1.0, 0.0]) == 0.0


async def test_semantic_missing_api_url():
    rule = SemanticSimilarityRule({"reference": "hello world"})
    result = await rule.evaluate(_case(), [_turn("hello")])
    assert result.passed is False
    assert "api_url" in result.reason


async def test_semantic_missing_reference():
    rule = SemanticSimilarityRule({"api_url": "http://mock/embed"})
    result = await rule.evaluate(_case(), [_turn("hello")])
    assert result.passed is False
    assert "reference" in result.reason


async def test_semantic_high_similarity_passes():
    rule = SemanticSimilarityRule({
        "api_url": "http://mock/embed",
        "reference": "hello world",
        "min_score": 0.8,
    })
    vec = [1.0, 0.0, 0.0]
    mock_resp = MagicMock()
    mock_resp.raise_for_status = MagicMock()
    mock_resp.json = MagicMock(return_value={"data": [{"embedding": vec}]})

    with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient:
        instance = MockClient.return_value.__aenter__.return_value
        instance.post = AsyncMock(return_value=mock_resp)
        result = await rule.evaluate(_case(), [_turn("hello world")])

    assert result.passed is True
    assert result.score == pytest.approx(1.0)


async def test_semantic_low_similarity_fails():
    rule = SemanticSimilarityRule({
        "api_url": "http://mock/embed",
        "reference": "hello world",
        "min_score": 0.9,
    })
    call_count = {"n": 0}

    async def mock_post(url, **kwargs):
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        # First call (reply): orthogonal to reference
        vecs = [[1.0, 0.0], [0.0, 1.0]]
        mock_resp.json = MagicMock(return_value={"data": [{"embedding": vecs[call_count["n"]]}]})
        call_count["n"] += 1
        return mock_resp

    with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient:
        instance = MockClient.return_value.__aenter__.return_value
        instance.post = AsyncMock(side_effect=mock_post)
        result = await rule.evaluate(_case(), [_turn("completely different")])

    assert result.passed is False


async def test_semantic_api_error_fails_gracefully():
    rule = SemanticSimilarityRule({
        "api_url": "http://mock/embed",
        "reference": "ref",
    })
    with patch("agenteval.evaluation.rules.semantic.httpx.AsyncClient") as MockClient:
        instance = MockClient.return_value.__aenter__.return_value
        instance.post = AsyncMock(side_effect=Exception("timeout"))
        result = await rule.evaluate(_case(), [_turn("reply")])

    assert result.passed is False
    assert "embedding 调用失败" in result.reason


async def test_semantic_empty_dialog():
    rule = SemanticSimilarityRule({"api_url": "http://x", "reference": "ref"})
    result = await rule.evaluate(_case(), [])
    assert result.passed is False


# ── Rule combination logic (engine integration) ───────────────────────────


def _make_target() -> EvalTarget:
    return EvalTarget(
        id="t-1", name="t", platform=PlatformType.AI_DIGITAL_EMPLOYEE,
        channel_type=__import__("agenteval.models", fromlist=["ChannelType"]).ChannelType.TUTU_API,
        channel_config={"base_url": "http://x", "token": "x", "tenant": "t",
                        "chat_channel_id": "c", "chat_contact_id": "u"},
        status=TargetStatus.ACTIVE,
    )


def _build_engine(scenario, session) -> EvalEngine:
    engine = EvalEngine(target=_make_target(), scenario=scenario, session=session)
    engine.channel = MockChannel()
    return engine


async def test_rule_logic_all_passes_when_all_pass(db_session):
    scenario = Scenario(id="s1", name="s", cases=[Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=[
            EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}),  # MockChannel replies "echo: q-1"
            EvalRuleConfig(type="response_time", params={"max_ms": 99999}),
        ],
        rule_logic=RuleLogic.ALL,
    )])
    engine = _build_engine(scenario, db_session)
    run = await engine.run()
    assert run.status == RunStatus.COMPLETED
    assert run.summary.passed_cases == 1


async def test_rule_logic_all_fails_when_one_fails(db_session):
    scenario = Scenario(id="s1", name="s", cases=[Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=[
            EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}),   # passes
            EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}),  # fails
        ],
        rule_logic=RuleLogic.ALL,
    )])
    engine = _build_engine(scenario, db_session)
    run = await engine.run()
    assert run.status == RunStatus.COMPLETED
    assert run.summary.failed_cases == 1


async def test_rule_logic_any_passes_when_one_passes(db_session):
    scenario = Scenario(id="s1", name="s", cases=[Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=[
            EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}),           # passes
            EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}), # fails
        ],
        rule_logic=RuleLogic.ANY,
    )])
    engine = _build_engine(scenario, db_session)
    run = await engine.run()
    assert run.status == RunStatus.COMPLETED
    assert run.summary.passed_cases == 1


async def test_rule_logic_weighted_passes_above_threshold(db_session):
    scenario = Scenario(id="s1", name="s", cases=[Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=[
            EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=0.8),           # passes
            EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}, weight=0.2), # fails
        ],
        rule_logic=RuleLogic.WEIGHTED,
        rule_pass_threshold=0.6,  # weighted score = 0.8/(0.8+0.2)=0.8 >= 0.6 → pass
    )])
    engine = _build_engine(scenario, db_session)
    run = await engine.run()
    assert run.status == RunStatus.COMPLETED
    assert run.summary.passed_cases == 1


async def test_rule_logic_weighted_fails_below_threshold(db_session):
    scenario = Scenario(id="s1", name="s", cases=[Case(
        id="c1", type=CaseType.SINGLE, messages=["hi"],
        eval_rules=[
            EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=0.2),           # passes
            EvalRuleConfig(type="keyword_match", params={"keywords": ["__IMPOSSIBLE__"]}, weight=0.8), # fails
        ],
        rule_logic=RuleLogic.WEIGHTED,
        rule_pass_threshold=0.6,  # weighted score = 0.2/(0.2+0.8)=0.2 < 0.6 → fail
    )])
    engine = _build_engine(scenario, db_session)
    run = await engine.run()
    assert run.status == RunStatus.COMPLETED
    assert run.summary.failed_cases == 1
