"""Tests for chat-agent-authorization: CallerIdentity, verification flow,
WhatsApp auto-linking, PII redaction, and AgentDeps construction safety."""

from __future__ import annotations

from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.agents.deps import AgentDeps, CallerIdentity
from app.db.base import utcnow
from app.schemas.agent_config import AgentConfig

pytestmark = pytest.mark.anyio

VERIFICATION_MOD = "app.agents.tools.verification"


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


def _make_caller(
    *,
    channel: str = "dashboard",
    verified: bool = True,
    identity_key: str = "test-user",
    customer_id: str | None = None,
    conversation_id: str | None = "conv-1",
) -> CallerIdentity:
    return CallerIdentity(
        channel=channel,
        identity_key=identity_key,
        customer_id=customer_id,
        verified=verified,
        conversation_id=conversation_id,
    )


def _make_deps(caller: CallerIdentity | None = None) -> AgentDeps:
    return AgentDeps(
        session=AsyncMock(),
        restaurant_id="rest-1",
        http_client=AsyncMock(),
        caller=caller or _make_caller(),
        language="nl",
        agent_config=AgentConfig(),
    )


def _mock_ctx(caller: CallerIdentity | None = None) -> MagicMock:
    ctx = MagicMock()
    ctx.deps = _make_deps(caller)
    return ctx


def _scalar_result(value):
    res = MagicMock()
    res.scalar_one_or_none.return_value = value
    return res


def _scalars_result(values: list):
    res = MagicMock()
    scalars = MagicMock()
    scalars.all.return_value = values
    res.scalars.return_value = scalars
    return res


def _scalar_count(count: int):
    res = MagicMock()
    res.scalar.return_value = count
    return res


def _mock_reservation(*, email: str = "jan@test.com", status: str = "confirmed"):
    r = MagicMock()
    r.guest_email = email
    r.status = status
    r.restaurant_id = "rest-1"
    return r


def _mock_verification(
    *,
    email: str = "jan@test.com",
    code_hash: str = "",
    attempts: int = 0,
    verified_at=None,
    expires_at=None,
):
    v = MagicMock()
    v.id = "verif-1"
    v.email = email
    v.code_hash = code_hash
    v.attempts = attempts
    v.verified_at = verified_at
    v.expires_at = expires_at or (utcnow() + timedelta(minutes=10))
    return v


def _mock_customer(*, id: str = "cust-1", email: str = "jan@test.com"):
    c = MagicMock()
    c.id = id
    c.email = email
    return c


def _mock_conversation(*, id: str = "conv-1", customer_id: str | None = None):
    c = MagicMock()
    c.id = id
    c.customer_id = customer_id
    return c


# ===================================================================
# 12.2 AgentDeps construction without caller must fail
# ===================================================================


class TestAgentDepsRequiresCaller:
    def test_construction_without_caller_raises(self):
        """AgentDeps construction without caller= must raise TypeError."""
        with pytest.raises(TypeError):
            AgentDeps(  # type: ignore[missing-argument]
                session=AsyncMock(),
                restaurant_id="rest-1",
                http_client=AsyncMock(),
                # no caller= argument
            )

    def test_construction_with_caller_succeeds(self):
        """AgentDeps construction with caller= succeeds."""
        deps = _make_deps()
        assert deps.caller.channel == "dashboard"
        assert deps.caller.verified is True


# ===================================================================
# 12.5 WhatsApp auto-linking
# ===================================================================


class TestWhatsAppAutoLinking:
    def test_unique_phone_match_populates_customer_id(self):
        """When exactly one customer matches the phone, customer_id is set."""
        caller = _make_caller(
            channel="whatsapp",
            verified=True,
            identity_key="+31612345678",
            customer_id="cust-1",  # auto-linked by handler
        )
        assert caller.customer_id == "cust-1"
        assert caller.verified is True

    def test_no_phone_match_leaves_customer_id_none(self):
        """When no customer matches, customer_id stays None."""
        caller = _make_caller(
            channel="whatsapp",
            verified=True,
            identity_key="+31600000000",
            customer_id=None,
        )
        assert caller.customer_id is None
        assert caller.verified is True

    def test_multiple_phone_matches_leaves_customer_id_none(self):
        """When multiple customers match, customer_id must be None (ambiguous)."""
        caller = _make_caller(
            channel="whatsapp",
            verified=True,
            identity_key="+31699999999",
            customer_id=None,  # ambiguous match
        )
        assert caller.customer_id is None
        assert caller.verified is True


# ===================================================================
# 12.6 Verification flow (send_verification_code + verify_code)
# ===================================================================


class TestSendVerificationCode:
    async def test_email_with_no_reservations_rejected(self):
        """Email not linked to any reservation returns no_reservations_found."""
        from app.agents.tools.verification import send_verification_code_impl

        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        ctx.deps.session.execute.return_value = _scalar_result(None)

        result = await send_verification_code_impl(ctx, "nobody@test.com")
        assert result == {"error": "no_reservations_found"}

    async def test_rate_limit_exceeded(self):
        """Fourth code request in one hour returns rate_limit_exceeded."""
        from app.agents.tools.verification import send_verification_code_impl

        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        # First call: reservation exists. Second call: rate limit count = 3.
        ctx.deps.session.execute.side_effect = [
            _scalar_result(_mock_reservation()),  # reservation check
            _scalar_count(3),  # rate limit check
        ]

        result = await send_verification_code_impl(ctx, "jan@test.com")
        assert result == {"error": "rate_limit_exceeded"}

    @patch(f"{VERIFICATION_MOD}.ScalewayEmailClient")
    @patch(f"{VERIFICATION_MOD}.bcrypt")
    async def test_successful_code_send(self, mock_bcrypt, mock_email_cls):
        """Successful send: generates code, hashes, stores, emails, returns status."""
        from app.agents.tools.verification import send_verification_code_impl

        mock_bcrypt.gensalt.return_value = b"$2b$12$salt"
        mock_bcrypt.hashpw.return_value = b"$2b$12$hashed"

        mock_email_instance = AsyncMock()
        mock_email_cls.return_value = mock_email_instance

        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        ctx.deps.session.execute.side_effect = [
            _scalar_result(_mock_reservation()),  # reservation check
            _scalar_count(0),  # rate limit check
            _scalar_result(MagicMock(name="TestRestaurant")),  # restaurant lookup
        ]
        ctx.deps.session.add = MagicMock()
        ctx.deps.session.flush = AsyncMock()

        result = await send_verification_code_impl(ctx, "jan@test.com")

        assert result == {"status": "code_sent", "email": "jan@test.com"}
        # Code was NOT returned in the response
        assert "code" not in result
        # Email client was called
        mock_email_instance.send_verification_code.assert_awaited_once()
        # bcrypt was used
        mock_bcrypt.hashpw.assert_called_once()


class TestVerifyCode:
    async def test_no_pending_code(self):
        """No matching pending verification returns no_pending_code."""
        from app.agents.tools.verification import verify_code_impl

        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        ctx.deps.session.execute.return_value = _scalar_result(None)

        result = await verify_code_impl(ctx, "jan@test.com", "123456")
        assert result == {"error": "no_pending_code"}

    async def test_max_attempts_exceeded(self):
        """Fourth wrong attempt returns max_attempts_exceeded."""
        from app.agents.tools.verification import verify_code_impl

        verif = _mock_verification(attempts=3)  # will become 4 after increment
        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        ctx.deps.session.execute.return_value = _scalar_result(verif)
        ctx.deps.session.flush = AsyncMock()

        result = await verify_code_impl(ctx, "jan@test.com", "000000")
        assert result == {"error": "max_attempts_exceeded"}
        assert verif.attempts == 4

    @patch(f"{VERIFICATION_MOD}.bcrypt")
    async def test_invalid_code(self, mock_bcrypt):
        """Wrong code returns invalid_code with attempts_remaining."""
        from app.agents.tools.verification import verify_code_impl

        mock_bcrypt.checkpw.return_value = False

        verif = _mock_verification(attempts=0, code_hash="$2b$12$hashed")
        caller = _make_caller(channel="website", verified=False)
        ctx = _mock_ctx(caller)
        ctx.deps.session.execute.return_value = _scalar_result(verif)
        ctx.deps.session.flush = AsyncMock()

        result = await verify_code_impl(ctx, "jan@test.com", "999999")
        assert result["error"] == "invalid_code"
        assert result["attempts_remaining"] == 2  # 3 - 1

    @patch(f"{VERIFICATION_MOD}.bcrypt")
    async def test_correct_code_links_customer(self, mock_bcrypt):
        """Correct code: verifies, links customer, updates caller."""
        from app.agents.tools.verification import verify_code_impl

        mock_bcrypt.checkpw.return_value = True

        verif = _mock_verification(attempts=0, code_hash="$2b$12$hashed")
        customer = _mock_customer(id="cust-1")
        conversation = _mock_conversation(id="conv-1")

        caller = _make_caller(channel="website", verified=False, conversation_id="conv-1")
        ctx = _mock_ctx(caller)

        # execute calls: 1) find verification, 2) find customer, 3) find conversation
        ctx.deps.session.execute.side_effect = [
            _scalar_result(verif),
            _scalar_result(customer),
            _scalar_result(conversation),
        ]
        ctx.deps.session.flush = AsyncMock()

        result = await verify_code_impl(ctx, "jan@test.com", "123456")

        assert result["status"] == "verified"
        assert result["customer_linked"] is True
        # Inline steer keeps the model from emitting a fresh greeting after
        # verification (regression: agent forgot the original cancel intent
        # and replied "How can I help you today?").
        assert "next_action" in result
        assert "ORIGINAL" in result["next_action"]
        assert ctx.deps.caller.verified is True
        assert ctx.deps.caller.customer_id == "cust-1"
        assert verif.verified_at is not None
        assert conversation.customer_id == "cust-1"

    @patch(f"{VERIFICATION_MOD}.bcrypt")
    async def test_correct_code_without_customer(self, mock_bcrypt):
        """Correct code but no customer record: verified=True, customer_linked=False."""
        from app.agents.tools.verification import verify_code_impl

        mock_bcrypt.checkpw.return_value = True

        verif = _mock_verification(attempts=0, code_hash="$2b$12$hashed")

        caller = _make_caller(channel="website", verified=False, conversation_id="conv-1")
        ctx = _mock_ctx(caller)

        ctx.deps.session.execute.side_effect = [
            _scalar_result(verif),
            _scalar_result(None),  # no customer found
        ]
        ctx.deps.session.flush = AsyncMock()

        result = await verify_code_impl(ctx, "jan@test.com", "123456")

        assert result["status"] == "verified"
        assert result["customer_linked"] is False
        assert "next_action" in result
        assert ctx.deps.caller.verified is True
        assert ctx.deps.caller.customer_id is None


# ===================================================================
# 12.8 PII redaction
# ===================================================================


class TestPiiRedaction:
    async def test_dashboard_includes_guest_email(self):
        """Dashboard caller gets guest_email in find_reservation results."""
        from app.agents.tools.reservation import find_reservation_impl

        caller = _make_caller(channel="dashboard", verified=True)
        ctx = _mock_ctx(caller)

        r = MagicMock()
        r.id = "r1"
        r.reserved_at = MagicMock()
        r.reserved_at.isoformat.return_value = "2026-06-15T19:00:00"
        r.party_size = 2
        r.status = "confirmed"
        r.guest_name = "Jan"
        r.guest_email = "jan@test.com"

        ctx.deps.session.execute.return_value = _scalars_result([r])

        result = await find_reservation_impl(ctx, customer_email="jan@test.com")
        assert "guest_email" in result["reservations"][0]
        assert result["reservations"][0]["guest_email"] == "jan@test.com"

    async def test_non_dashboard_omits_guest_email_and_phone(self):
        """Non-dashboard caller: guest_email and guest_phone omitted."""
        from app.agents.tools.reservation import find_reservation_impl

        caller = _make_caller(channel="website", verified=True, customer_id="cust-1")
        ctx = _mock_ctx(caller)

        r = MagicMock()
        r.id = "r1"
        r.reserved_at = MagicMock()
        r.reserved_at.isoformat.return_value = "2026-06-15T19:00:00"
        r.party_size = 2
        r.status = "confirmed"
        r.guest_name = "Jan"
        r.guest_email = "jan@test.com"
        r.guest_phone = "+31600000000"
        r.customer_id = "cust-1"

        ctx.deps.session.execute.return_value = _scalars_result([r])

        result = await find_reservation_impl(ctx)
        reservation = result["reservations"][0]
        assert "guest_email" not in reservation
        assert "guest_phone" not in reservation
        assert reservation["guest_name"] == "Jan"


# ===================================================================
# Tool filtering: verification tools visibility
# ===================================================================


class TestVerificationToolFiltering:
    def _make_tool_def(self, name: str):
        from pydantic_ai import ToolDefinition

        return ToolDefinition(name=name, description=f"Tool {name}", parameters_json_schema={})

    async def test_unverified_website_sees_verification_tools(self):
        from app.agents.restaurant import _filter_tools

        caller = _make_caller(channel="website", verified=False)
        deps = _make_deps(caller)
        deps.enabled_capabilities = {"faq", "reservation"}
        ctx = MagicMock()
        ctx.deps = deps

        defs = [
            self._make_tool_def("search_knowledge_base"),
            self._make_tool_def("find_reservation"),
            self._make_tool_def("send_verification_code"),
            self._make_tool_def("verify_code"),
        ]

        result = await _filter_tools(ctx, defs)
        assert result is not None
        names = {td.name for td in result}
        assert "send_verification_code" in names
        assert "verify_code" in names
        assert "search_knowledge_base" in names
        assert "find_reservation" in names

    async def test_dashboard_never_sees_verification_tools(self):
        from app.agents.restaurant import _filter_tools

        caller = _make_caller(channel="dashboard", verified=True)
        deps = _make_deps(caller)
        deps.enabled_capabilities = {"faq", "reservation"}
        ctx = MagicMock()
        ctx.deps = deps

        defs = [
            self._make_tool_def("find_reservation"),
            self._make_tool_def("send_verification_code"),
            self._make_tool_def("verify_code"),
        ]

        result = await _filter_tools(ctx, defs)
        assert result is not None
        names = {td.name for td in result}
        assert "send_verification_code" not in names
        assert "verify_code" not in names
        assert "find_reservation" in names

    async def test_verified_caller_does_not_see_verification_tools(self):
        from app.agents.restaurant import _filter_tools

        caller = _make_caller(channel="website", verified=True, customer_id="cust-1")
        deps = _make_deps(caller)
        deps.enabled_capabilities = {"faq", "reservation"}
        ctx = MagicMock()
        ctx.deps = deps

        defs = [
            self._make_tool_def("find_reservation"),
            self._make_tool_def("send_verification_code"),
            self._make_tool_def("verify_code"),
        ]

        result = await _filter_tools(ctx, defs)
        assert result is not None
        names = {td.name for td in result}
        assert "send_verification_code" not in names
        assert "verify_code" not in names

    async def test_whatsapp_auto_linked_does_not_see_verification_tools(self):
        from app.agents.restaurant import _filter_tools

        caller = _make_caller(channel="whatsapp", verified=True, customer_id="cust-1")
        deps = _make_deps(caller)
        deps.enabled_capabilities = {"faq", "reservation"}
        ctx = MagicMock()
        ctx.deps = deps

        defs = [
            self._make_tool_def("send_verification_code"),
            self._make_tool_def("verify_code"),
        ]

        result = await _filter_tools(ctx, defs)
        assert result == []
