import asyncio
from unittest.mock import patch, MagicMock, AsyncMock

from app.services.chat.tools import TOOL_SCHEMAS, TOOL_EXECUTORS
from app.services.chat.tools_settings import TICKET_CATEGORIES

EXPECTED_NAMES = {
    "search_knowledge_base",
    "create_or_update_ticket",
    "submit_feedback",
    "request_human_takeover",
    "send_email",
    "attach_resources",
    "send_document",
    "recommend_products",
    "check_availability",
    "book_meeting",
    "cancel_meeting",
}

def test_tool_schemas_shape():
    assert {t["function"]["name"] for t in TOOL_SCHEMAS} == EXPECTED_NAMES
    for t in TOOL_SCHEMAS:
        assert t["type"] == "function"
        fn = t["function"]
        assert fn["description"]
        params = fn["parameters"]
        assert params["type"] == "object"
        assert "properties" in params

def test_ticket_schema_required_fields():
    ticket = next(t for t in TOOL_SCHEMAS if t["function"]["name"] == "create_or_update_ticket")
    props = ticket["function"]["parameters"]["properties"]
    for field in ("user_name", "heading", "content", "priority", "email", "contact_no"):
        assert field in props


def test_ticket_schema_has_a_category_enum():
    ticket = next(t for t in TOOL_SCHEMAS if t["function"]["name"] == "create_or_update_ticket")
    params = ticket["function"]["parameters"]
    assert "category" in params["properties"]
    assert params["properties"]["category"]["enum"] == list(TICKET_CATEGORIES)
    assert "category" in params["required"]


def test_schema_and_executor_registries_agree():
    """The registry replaces the old if/elif chain; this is the guard that keeps them coupled."""
    schema_names = {t["function"]["name"] for t in TOOL_SCHEMAS}
    assert schema_names == set(TOOL_EXECUTORS.keys())
    for name, handler in TOOL_EXECUTORS.items():
        assert asyncio.iscoroutinefunction(handler), f"{name} executor must be async"


def _run(coro):
    return asyncio.run(coro)

def _ctx(**over):
    ctx = {"tenant_id": "t1", "thread_id": "th1", "user_name": "Jo",
           "user_id": "u1", "history": []}
    ctx.update(over)
    return ctx

TICKET_ARGS = {
    "user_name": "Jo", "heading": "Login broken", "content": "Cannot log in",
    "priority": "High", "email": "jo@x.com", "contact_no": "123",
    "category": "General enquiries",
}

@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.create_ticket")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_ticket_created_when_none_exists(mock_by_thread, mock_create, mock_get, mock_publish):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = None
    mock_create.return_value = 1
    mock_get.return_value = {"ticket_id": "TICK-X"}
    result = _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    assert result["status"] == "created"
    assert result["ticket_id"].startswith("TICK-")
    mock_create.assert_called_once()

@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.create_ticket")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_a_created_tickets_category_reaches_create_ticket(
        mock_by_thread, mock_create, mock_get, mock_publish):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = None
    mock_create.return_value = 1
    mock_get.return_value = {"ticket_id": "TICK-X"}
    _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    ticket_data = mock_create.call_args[0][1]
    assert ticket_data["category"] == "General enquiries"


@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.create_ticket")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_a_ticket_created_with_no_category_defaults_to_general_enquiries(
        mock_by_thread, mock_create, mock_get, mock_publish):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = None
    mock_create.return_value = 1
    mock_get.return_value = {"ticket_id": "TICK-X"}
    args = {k: v for k, v in TICKET_ARGS.items() if k != "category"}
    _run(execute_create_or_update_ticket(dict(args), _ctx()))
    assert mock_create.call_args[0][1]["category"] == "General enquiries"

@patch("app.services.chat.tools._notify_ticket")
@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_ticket_consolidated_when_exists(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish, mock_notify):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old", "content": "Old issue"}
    mock_llm.chat.completions.create.return_value.choices = [
        MagicMock(message=MagicMock(content='{"heading": "Merged", "content": "Both issues"}'))
    ]
    mock_update.return_value = True
    mock_get.return_value = {"ticket_id": "TICK-OLD"}
    result = _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    assert result["status"] == "updated"
    assert result["ticket_id"] == "TICK-OLD"
    mock_update.assert_called_once()
    update_data = mock_update.call_args[0][2]
    assert update_data["heading"] == "Merged"
    assert update_data["content"] == "Both issues"
    assert update_data["priority"] == TICKET_ARGS["priority"]

@patch("app.services.chat.tools._notify_ticket")
@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_updating_a_ticket_preserves_its_existing_category(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish, mock_notify):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old",
                                   "content": "Old issue", "category": "Sales"}
    mock_llm.chat.completions.create.return_value.choices = [
        MagicMock(message=MagicMock(content='{"heading": "Merged", "content": "Both issues"}'))
    ]
    mock_update.return_value = True
    mock_get.return_value = {"ticket_id": "TICK-OLD"}
    args = dict(TICKET_ARGS)
    del args["category"]  # model didn't supply one on this turn
    _run(execute_create_or_update_ticket(args, _ctx()))
    update_data = mock_update.call_args[0][2]
    assert update_data["category"] == "Sales"

@patch("app.services.chat.tools._notify_ticket")
@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_ticket_consolidation_fallback_on_llm_exception(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish, mock_notify):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old", "content": "Old issue"}
    mock_llm.chat.completions.create.side_effect = Exception("LLM unavailable")
    mock_update.return_value = True
    mock_get.return_value = {"ticket_id": "TICK-OLD"}
    result = _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    assert result["status"] == "updated"
    assert result["ticket_id"] == "TICK-OLD"
    update_data = mock_update.call_args[0][2]
    assert update_data["heading"] == TICKET_ARGS["heading"]
    assert "Old issue" in update_data["content"]
    assert "[Update]:" in update_data["content"]
    assert TICKET_ARGS["content"] in update_data["content"]
    assert update_data["priority"] == TICKET_ARGS["priority"]

@patch("app.services.chat.tools._notify_ticket")
@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_ticket_consolidation_fallback_on_bad_json(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish, mock_notify):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old", "content": "Old issue"}
    mock_llm.chat.completions.create.return_value.choices = [
        MagicMock(message=MagicMock(content="not json at all"))
    ]
    mock_update.return_value = True
    mock_get.return_value = {"ticket_id": "TICK-OLD"}
    result = _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    assert result["status"] == "updated"
    assert result["ticket_id"] == "TICK-OLD"
    update_data = mock_update.call_args[0][2]
    assert update_data["heading"] == TICKET_ARGS["heading"]
    assert "Old issue" in update_data["content"]
    assert "[Update]:" in update_data["content"]
    assert TICKET_ARGS["content"] in update_data["content"]
    assert update_data["priority"] == TICKET_ARGS["priority"]

@patch("app.services.chat.tools._notify_ticket")
@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_updating_a_ticket_notifies_with_the_full_refetched_record(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish, mock_notify):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old",
                                   "content": "Old issue", "category": "Sales"}
    mock_llm.chat.completions.create.return_value.choices = [
        MagicMock(message=MagicMock(content='{"heading": "Merged", "content": "Both issues"}'))
    ]
    mock_update.return_value = True
    full_ticket = {"ticket_id": "TICK-OLD", "heading": "Merged", "content": "Both issues",
                  "category": "Sales", "email": "jo@x.com", "contact_no": "123", "user_name": "Jo"}
    mock_get.return_value = full_ticket
    _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    mock_notify.assert_called_once_with("t1", full_ticket, updated=True)


@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.get_ticket")
@patch("app.services.chat.tools.update_ticket")
@patch("app.services.chat.tools.openai_client")
@patch("app.services.chat.tools.get_ticket_by_thread")
def test_a_failed_refetch_after_update_skips_notification_without_erroring(
        mock_by_thread, mock_llm, mock_update, mock_get, mock_publish):
    from app.services.chat.tools import execute_create_or_update_ticket
    mock_by_thread.return_value = {"ticket_id": "TICK-OLD", "heading": "Old", "content": "Old issue"}
    mock_llm.chat.completions.create.return_value.choices = [
        MagicMock(message=MagicMock(content='{"heading": "Merged", "content": "Both issues"}'))
    ]
    mock_update.return_value = True
    mock_get.return_value = None  # e.g. a transient read failure right after the write
    result = _run(execute_create_or_update_ticket(dict(TICKET_ARGS), _ctx()))
    assert result["status"] == "updated"


@patch("app.services.chat.tools.insert_feedback")
def test_submit_feedback(mock_insert):
    from app.services.chat.tools import execute_submit_feedback
    history = [
        {"role": "user", "content": "How much is pro?"},
        {"role": "assistant", "content": "$99/mo"},
    ]
    result = _run(execute_submit_feedback(
        {"question": "Helpful?", "answer": "Yes"}, _ctx(history=history)))
    assert result["status"] == "saved"
    payload = mock_insert.call_args[0][1]
    assert payload["thread_id"] == "th1"
    assert payload["metadata"]["is_explicit_feedback"] is True
    assert payload["metadata"]["rated_conversation"]["last_user_query"] == "How much is pro?"

@patch("app.services.chat.tools.publish_event", new_callable=AsyncMock)
@patch("app.services.chat.tools.set_intervention_status")
def test_request_human_takeover(mock_set, mock_publish):
    from app.services.chat.tools import execute_request_human_takeover
    result = _run(execute_request_human_takeover({"reason": "asked for human"}, _ctx()))
    assert result["status"] == "flagged"
    mock_set.assert_called_once_with("t1", "th1", True, user_id="u1")


def test_attach_resources_executor_reports_count():
    from app.services.chat.tools import execute_attach_resources
    result = _run(execute_attach_resources(
        {"resources": [{"label": "Pricing", "url": "https://x/p"}]}, _ctx()))
    assert result == {"status": "attached", "count": 1}


def test_dispatch_unknown_tool():
    from app.services.chat.tools import dispatch_tool
    result = _run(dispatch_tool("does_not_exist", {}, _ctx()))
    assert result["status"] == "error"


# --- search_knowledge_base -------------------------------------------------

@patch("app.services.chat.tools.search_vector_data")
@patch("app.services.chat.tools.get_embeddings")
def test_search_returns_content_and_source(mock_embed, mock_search):
    from app.services.chat.tools import execute_search_knowledge_base
    mock_embed.return_value = [0.1, 0.2]
    mock_search.return_value = [
        {"content": "BrandForge is free.", "metadata": {"source": "https://galaxiq.ai/pricing"}},
        {"content": "Aivora is a chatbot.", "metadata": {"source_url": "https://galaxiq.ai/aivora"}},
        {"content": "No metadata chunk.", "metadata": None},
    ]
    result = _run(execute_search_knowledge_base({"query": "brandforge price"}, _ctx()))
    assert result["count"] == 3
    assert result["note"] is None
    assert result["results"][0]["content"] == "BrandForge is free."
    assert result["results"][0]["source"] == "https://galaxiq.ai/pricing"
    assert result["results"][1]["source"] == "https://galaxiq.ai/aivora"
    assert result["results"][2]["source"] == "Internal Knowledge Base"
    mock_embed.assert_called_once_with("brandforge price", tenant_id="t1", user_id="u1")
    mock_search.assert_called_once_with("t1", [0.1, 0.2], limit=5)


@patch("app.services.chat.tools.search_vector_data")
@patch("app.services.chat.tools.get_embeddings")
def test_search_maps_top_images(mock_embed, mock_search):
    from app.services.chat.tools import execute_search_knowledge_base
    mock_embed.return_value = [0.1]
    mock_search.return_value = [{
        "content": "Product page",
        "metadata": {"source": "https://x/p", "top_images": [
            {"url": "https://x/1.png", "label": "One"},
            "https://x/2.png",
            {"label": "no url"},
            {"url": "https://x/4.png"},
        ]},
    }]
    result = _run(execute_search_knowledge_base({"query": "product"}, _ctx()))
    # Only the first 3 candidates are considered; the url-less dict is skipped.
    imgs = result["results"][0]["images"]
    assert imgs == ["https://x/1.png", "https://x/2.png"]


@patch("app.services.chat.tools.search_vector_data")
@patch("app.services.chat.tools.get_embeddings")
def test_search_empty_results_returns_retry_note(mock_embed, mock_search):
    from app.services.chat.tools import execute_search_knowledge_base
    mock_embed.return_value = [0.1]
    mock_search.return_value = []
    result = _run(execute_search_knowledge_base({"query": "hipaa baa"}, _ctx()))
    assert result["count"] == 0
    assert result["results"] == []
    assert "different wording" in result["note"]


def test_search_empty_query_short_circuits():
    from app.services.chat.tools import execute_search_knowledge_base
    result = _run(execute_search_knowledge_base({"query": "   "}, _ctx()))
    assert result == {"results": [], "count": 0, "note": "Empty query."}


@patch("app.services.chat.tools.get_embeddings")
def test_search_quota_error_propagates(mock_embed):
    from app.services.chat.tools import execute_search_knowledge_base
    from app.services.infra.quotas import QuotaExceededError
    mock_embed.side_effect = QuotaExceededError("cap hit")
    try:
        _run(execute_search_knowledge_base({"query": "x"}, _ctx()))
        raised = False
    except QuotaExceededError:
        raised = True
    assert raised


@patch("app.services.chat.tools.get_embeddings")
def test_search_failure_is_soft(mock_embed):
    from app.services.chat.tools import execute_search_knowledge_base
    mock_embed.side_effect = RuntimeError("embed down")
    result = _run(execute_search_knowledge_base({"query": "x"}, _ctx()))
    assert result["count"] == 0
    assert result["note"] == "Search failed."


@patch("app.services.chat.tools.asyncio.create_task")
def test_fire_and_forget_email_returns_immediately_without_awaiting_send(mock_create_task):
    from app.services.chat.tools import _fire_and_forget_email
    mock_create_task.return_value = MagicMock()
    _fire_and_forget_email("a@b.com", "Subject", "Body", "org_test")
    mock_create_task.assert_called_once()


@patch("app.services.chat.tools.email_configured", return_value=True)
@patch("app.services.chat.tools.get_tool_settings")
@patch("app.services.chat.tools._fire_and_forget_email")
def test_notify_ticket_emails_only_matching_category(mock_fire, mock_settings, mock_configured):
    from app.services.chat.tools import _notify_ticket
    mock_settings.return_value = {"ticket_emails": [
        {"email": "sales@acme.com", "category": "Sales"},
        {"email": "support@acme.com", "category": "General enquiries"},
    ]}
    ticket_data = {"ticket_id": "TICK-1", "category": "Sales", "heading": "h",
                   "content": "c", "priority": "High", "user_name": "Jo"}
    _notify_ticket("org_test", ticket_data)
    team_calls = [c for c in mock_fire.call_args_list if c.args[0] == "sales@acme.com"]
    assert len(team_calls) == 1
    assert not any(c.args[0] == "support@acme.com" for c in mock_fire.call_args_list)


@patch("app.services.chat.tools.get_tool_settings")
@patch("app.services.chat.tools._fire_and_forget_email")
def test_notify_ticket_sends_nothing_when_no_category_match(mock_fire, mock_settings):
    from app.services.chat.tools import _notify_ticket
    mock_settings.return_value = {"ticket_emails": [
        {"email": "sales@acme.com", "category": "Sales"}]}
    ticket_data = {"ticket_id": "TICK-1", "category": "Business", "heading": "h",
                   "content": "c", "priority": "High", "user_name": "Jo"}
    _notify_ticket("org_test", ticket_data)
    assert mock_fire.call_count == 0


@patch("app.services.chat.tools.email_configured", return_value=True)
@patch("app.services.chat.tools.get_tool_settings")
@patch("app.services.chat.tools._fire_and_forget_email")
def test_notify_ticket_confirms_to_the_customer_when_email_given(mock_fire, mock_settings, mock_configured):
    from app.services.chat.tools import _notify_ticket
    mock_settings.return_value = {"ticket_emails": []}
    ticket_data = {"ticket_id": "TICK-1", "category": "Sales", "heading": "h",
                   "content": "c", "priority": "High", "user_name": "Jo", "email": "jo@x.com"}
    _notify_ticket("org_test", ticket_data)
    customer_calls = [c for c in mock_fire.call_args_list if c.args[0] == "jo@x.com"]
    assert len(customer_calls) == 1


@patch("app.services.chat.tools.get_tool_settings")
@patch("app.services.chat.tools._fire_and_forget_email")
def test_notify_ticket_skips_customer_email_when_none_given(mock_fire, mock_settings):
    from app.services.chat.tools import _notify_ticket
    mock_settings.return_value = {"ticket_emails": []}
    ticket_data = {"ticket_id": "TICK-1", "category": "Sales", "heading": "h",
                   "content": "c", "priority": "High", "user_name": "Jo"}
    _notify_ticket("org_test", ticket_data)
    assert mock_fire.call_count == 0


@patch("app.services.chat.tools._fire_and_forget_email")
@patch("app.services.chat.tools.get_email_settings")
@patch("app.services.chat.tools.email_configured", return_value=True)
def test_send_email_returns_queued_without_waiting_on_the_send(
        mock_configured, mock_email_settings, mock_fire):
    from app.services.chat.tools import execute_send_email
    mock_email_settings.return_value = {"enabled": True}
    args = {"to_email": "user@example.com", "subject": "Hi", "body": "Body text"}
    result = _run(execute_send_email(args, _ctx()))
    assert result == {"status": "queued", "to": "user@example.com"}
    mock_fire.assert_called_once()
    assert mock_fire.call_args[0][0] == "user@example.com"
