"""
Tests for services/matrix-bridge-dagi/app/mixed_routing.py

Covers:
  - parse_mixed_room_map: valid, errors, defaults
  - route_message: slash, @mention, colon-mention, fallback, unknown agent
  - reply_prefix: mixed vs single-agent rooms
"""

import sys
from pathlib import Path

import pytest

_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
if str(_BRIDGE) not in sys.path:
    sys.path.insert(0, str(_BRIDGE))

from app.mixed_routing import (  # noqa: E402
    MixedRoomConfig,
    MixedRoom,
    parse_mixed_room_map,
    route_message,
    reply_prefix,
    REASON_SLASH,
    REASON_AT_MENTION,
    REASON_COLON_MENTION,
    REASON_DEFAULT,
    REASON_REJECTED_UNKNOWN_AGENT,
    REASON_REJECTED_SLASH_TOO_LONG,
    REASON_REJECTED_NO_MAPPING,
)

ROOM_X = "!roomX:daarion.space"
ROOM_Y = "!roomY:daarion.space"
ALLOWED = frozenset({"sofiia", "helion", "druid", "nutra"})


# ── Parsing ────────────────────────────────────────────────────────────────────

def test_parse_single_mixed_room():
    raw = f"{ROOM_X}=sofiia,helion"
    cfg = parse_mixed_room_map(raw, "", ALLOWED)
    assert cfg.total_rooms == 1
    assert cfg.agents_for_room(ROOM_X) == ["sofiia", "helion"]
    assert cfg.default_agent(ROOM_X) == "sofiia"  # first in list


def test_parse_two_mixed_rooms():
    raw = f"{ROOM_X}=sofiia,helion;{ROOM_Y}=druid,nutra"
    cfg = parse_mixed_room_map(raw, "", ALLOWED)
    assert cfg.total_rooms == 2
    assert cfg.agents_for_room(ROOM_Y) == ["druid", "nutra"]
    assert cfg.default_agent(ROOM_Y) == "druid"


def test_parse_explicit_default():
    raw = f"{ROOM_X}=sofiia,helion"
    defaults = f"{ROOM_X}=helion"
    cfg = parse_mixed_room_map(raw, defaults, ALLOWED)
    assert cfg.default_agent(ROOM_X) == "helion"


def test_parse_explicit_default_not_in_agents_raises():
    raw = f"{ROOM_X}=sofiia,helion"
    defaults = f"{ROOM_X}=druid"   # druid not in agents for ROOM_X
    with pytest.raises(ValueError, match="Default agent"):
        parse_mixed_room_map(raw, defaults, ALLOWED)


def test_parse_duplicate_room_raises():
    raw = f"{ROOM_X}=sofiia;{ROOM_X}=helion"
    with pytest.raises(ValueError, match="Duplicate room_id"):
        parse_mixed_room_map(raw, "", ALLOWED)


def test_parse_unknown_agent_raises():
    raw = f"{ROOM_X}=sofiia,unknown_bot"
    with pytest.raises(ValueError, match="not in allowed_agents"):
        parse_mixed_room_map(raw, "", ALLOWED)


def test_parse_bad_room_id_raises():
    raw = "not-a-room-id=sofiia"
    with pytest.raises(ValueError, match="Invalid room_id"):
        parse_mixed_room_map(raw, "", ALLOWED)


def test_parse_empty_map_returns_empty():
    cfg = parse_mixed_room_map("", "", ALLOWED)
    assert cfg.total_rooms == 0


def test_parse_semicolons_with_spaces():
    raw = f" {ROOM_X}=sofiia,helion ; {ROOM_Y}=druid "
    cfg = parse_mixed_room_map(raw, "", ALLOWED)
    assert cfg.total_rooms == 2


def test_is_mixed_true_false():
    raw = f"{ROOM_X}=sofiia,helion"
    cfg = parse_mixed_room_map(raw, "", ALLOWED)
    assert cfg.is_mixed(ROOM_X) is True
    assert cfg.is_mixed(ROOM_Y) is False


def test_as_summary_shape():
    raw = f"{ROOM_X}=sofiia,helion;{ROOM_Y}=druid"
    cfg = parse_mixed_room_map(raw, "", ALLOWED)
    summary = cfg.as_summary()
    assert len(summary) == 2
    for entry in summary:
        assert "room_id" in entry
        assert "agents" in entry
        assert "default_agent" in entry


# ── Routing — slash command ────────────────────────────────────────────────────

def _make_cfg(room_id: str = ROOM_X, agents=("sofiia", "helion")) -> MixedRoomConfig:
    raw = f"{room_id}={','.join(agents)}"
    return parse_mixed_room_map(raw, "", frozenset(agents))


def test_slash_routes_to_correct_agent():
    cfg = _make_cfg()
    agent, reason, body = route_message("/helion tell me the weather", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "helion"
    assert reason == REASON_SLASH
    assert body == "tell me the weather"


def test_slash_case_insensitive():
    cfg = _make_cfg()
    agent, reason, _ = route_message("/Sofiia hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"
    assert reason == REASON_SLASH


def test_slash_empty_body_keeps_original():
    cfg = _make_cfg()
    agent, reason, body = route_message("/helion", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "helion"
    # body fallback: original text
    assert "/helion" in body


def test_slash_unknown_agent_returns_none():
    cfg = _make_cfg()
    agent, reason, _ = route_message("/druid hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent is None
    assert reason == REASON_REJECTED_UNKNOWN_AGENT


# ── Routing — @mention ────────────────────────────────────────────────────────

def test_at_mention_routes_correctly():
    cfg = _make_cfg()
    agent, reason, body = route_message("@sofiia what is the status?", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"
    assert reason == REASON_AT_MENTION
    assert body == "what is the status?"


def test_at_mention_unknown_falls_through_to_default():
    cfg = _make_cfg()
    # @unknown_bot — not in agents → falls through to colon check, then default
    agent, reason, _ = route_message("@unknown_bot hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"   # default
    assert reason == REASON_DEFAULT


# ── Routing — colon mention ───────────────────────────────────────────────────

def test_colon_mention_routes_correctly():
    cfg = _make_cfg()
    agent, reason, body = route_message("sofiia: can you help?", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"
    assert reason == REASON_COLON_MENTION
    assert body == "can you help?"


def test_colon_mention_unknown_falls_to_default():
    cfg = _make_cfg()
    agent, reason, _ = route_message("druid: hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"
    assert reason == REASON_DEFAULT


# ── Routing — priority order ──────────────────────────────────────────────────

def test_slash_beats_at_mention():
    """If text starts with slash, it should be slash-routed even if it also mentions @."""
    cfg = _make_cfg()
    agent, reason, _ = route_message("/helion @sofiia hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert reason == REASON_SLASH
    assert agent == "helion"


# ── Routing — default fallback ────────────────────────────────────────────────

def test_plain_message_routes_to_default():
    cfg = _make_cfg()
    agent, reason, body = route_message("plain message no routing token", ROOM_X, cfg, frozenset({"sofiia", "helion"}))
    assert agent == "sofiia"
    assert reason == REASON_DEFAULT
    assert body == "plain message no routing token"


def test_no_mapping_for_room_returns_none():
    cfg = _make_cfg(room_id=ROOM_X)
    agent, reason, _ = route_message("hello", ROOM_Y, cfg, ALLOWED)   # ROOM_Y not in config
    assert agent is None
    assert reason == "no_mapping"


# ── Reply prefix ──────────────────────────────────────────────────────────────

def test_reply_prefix_mixed_room():
    assert reply_prefix("sofiia", is_mixed=True) == "Sofiia: "
    assert reply_prefix("helion", is_mixed=True) == "Helion: "


def test_reply_prefix_single_room_empty():
    assert reply_prefix("sofiia", is_mixed=False) == ""


def test_reply_prefix_capitalises_first_letter():
    assert reply_prefix("druid", is_mixed=True) == "Druid: "
    assert reply_prefix("NUTRA", is_mixed=True) == "Nutra: "   # capitalize() normalises case


# ── M2.2: Guard rails ─────────────────────────────────────────────────────────

def test_max_agents_per_room_raises():
    """More agents than max → ValueError at parse time."""
    raw = f"{ROOM_X}=sofiia,helion,druid,nutra,alateya,yaromir"   # 6 agents
    allowed_6 = frozenset({"sofiia", "helion", "druid", "nutra", "alateya", "yaromir"})
    with pytest.raises(ValueError, match="MAX_AGENTS_PER_MIXED_ROOM"):
        parse_mixed_room_map(raw, "", allowed_6, max_agents_per_room=5)


def test_max_agents_per_room_exactly_at_limit_ok():
    """Exactly at limit should succeed."""
    raw = f"{ROOM_X}=sofiia,helion,druid,nutra,alateya"   # 5 = default limit
    allowed_5 = frozenset({"sofiia", "helion", "druid", "nutra", "alateya"})
    cfg = parse_mixed_room_map(raw, "", allowed_5, max_agents_per_room=5)
    assert len(cfg.agents_for_room(ROOM_X)) == 5


def test_slash_too_long_returns_rejected_reason():
    """Slash command token longer than max_slash_len → rejection, no fallthrough."""
    cfg = _make_cfg()
    long_token = "a" * 33   # > default 32
    agent, reason, _ = route_message(
        f"/{long_token} hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}),
        max_slash_len=32,
    )
    assert agent is None
    assert reason == REASON_REJECTED_SLASH_TOO_LONG


def test_slash_exactly_at_max_len_ok():
    """Slash token exactly at max_slash_len should NOT be rejected."""
    allowed = frozenset({"sofiia", "helion"})
    raw = f"{ROOM_X}=sofiia,helion"
    # Create a 10-char agent name (within limit) — use a mock allowed set
    cfg = parse_mixed_room_map(raw, "", allowed, max_agents_per_room=5)
    agent, reason, _ = route_message("/sofiia hi", ROOM_X, cfg, allowed, max_slash_len=32)
    assert agent == "sofiia"
    assert reason == REASON_SLASH


def test_unknown_slash_returns_rejected_unknown_agent():
    """Slash with valid-length token but unknown agent → REASON_REJECTED_UNKNOWN_AGENT."""
    cfg = _make_cfg()
    agent, reason, _ = route_message(
        "/druid hello", ROOM_X, cfg, frozenset({"sofiia", "helion"}),
        max_slash_len=32,
    )
    assert agent is None
    assert reason == REASON_REJECTED_UNKNOWN_AGENT


def test_no_mapping_returns_rejected_no_mapping():
    """Room not in config → REASON_REJECTED_NO_MAPPING."""
    cfg = _make_cfg(room_id=ROOM_X)
    agent, reason, _ = route_message("hello", ROOM_Y, cfg, ALLOWED, max_slash_len=32)
    assert agent is None
    assert reason == REASON_REJECTED_NO_MAPPING


def test_rejection_reasons_are_distinct_constants():
    """All rejection reason strings must differ from success reasons."""
    success = {REASON_SLASH, REASON_AT_MENTION, REASON_COLON_MENTION, REASON_DEFAULT}
    rejected = {REASON_REJECTED_UNKNOWN_AGENT, REASON_REJECTED_SLASH_TOO_LONG, REASON_REJECTED_NO_MAPPING}
    assert not success.intersection(rejected), "Rejection reasons must not overlap with success reasons"
