"""Tests for Phase 9 — AI-native layer.

Covers:
* Spec validation + registry idempotency (semantic, actions, prompts).
* Providers (echo + scripted).
* Embeddings + cosine similarity sanity.
* Vector store tenant isolation + top-k ordering.
* Tool wrapping an action validates payload.
* Conversation memory FIFO bound.
* Context builder requires a tenant and surfaces entities + recall + history.
* Agent runner respects allowed_tools and runs through the provider.
* Services: create_suggestion, execute_action (direct + approval flow),
  decide_suggestion (approve/reject).
* HTTP API: action execute, agent run, suggestion list/decide.
"""

from __future__ import annotations

import json

import pytest
from django.urls import reverse

from simorgh.apps.ai import (
    actions as actions_mod,
)
from simorgh.apps.ai import (
    agents as agents_mod,
)
from simorgh.apps.ai import (
    embeddings as embeddings_mod,
)
from simorgh.apps.ai import (
    memory as memory_mod,
)
from simorgh.apps.ai import (
    prompts as prompts_mod,
)
from simorgh.apps.ai import (
    providers as providers_mod,
)
from simorgh.apps.ai import (
    semantic as semantic_mod,
)
from simorgh.apps.ai import (
    services as services_mod,
)
from simorgh.apps.ai import (
    tools as tools_mod,
)
from simorgh.apps.ai import (
    vector_store as vs_mod,
)
from simorgh.apps.ai.actions import ActionError, ActionParam, ActionSpec, register_action
from simorgh.apps.ai.agents import AgentError, AgentSpec, register_agent, run_agent
from simorgh.apps.ai.context import ContextError, build_context
from simorgh.apps.ai.embeddings import HashEmbedder, cosine_similarity
from simorgh.apps.ai.memory import ConversationMemory
from simorgh.apps.ai.models import (
    ActionLogStatus,
    AIActionLog,
    AIApproval,
    AISuggestion,
    ApprovalDecision,
    SuggestionKind,
    SuggestionStatus,
)
from simorgh.apps.ai.prompts import PromptError, PromptTemplate, register_prompt
from simorgh.apps.ai.providers import (
    EchoProvider,
    Message,
    ProviderError,
    ScriptedProvider,
    register_provider,
)
from simorgh.apps.ai.semantic import (
    EntitySpec,
    FieldSpec,
    SemanticError,
    register_entity,
)
from simorgh.apps.ai.tools import ToolError, tool_for_action
from simorgh.apps.ai.vector_store import InMemoryVectorStore, VectorRecord


@pytest.fixture(autouse=True)
def _reset_registries():
    """Wipe in-memory state between tests so order does not matter."""
    semantic_mod.reset_for_tests()
    actions_mod.reset_for_tests()
    prompts_mod.reset_for_tests()
    providers_mod.reset_for_tests()
    embeddings_mod.reset_for_tests()
    vs_mod.reset_for_tests()
    tools_mod.reset_for_tests()
    agents_mod.reset_for_tests()
    yield
    semantic_mod.reset_for_tests()
    actions_mod.reset_for_tests()
    prompts_mod.reset_for_tests()
    providers_mod.reset_for_tests()
    embeddings_mod.reset_for_tests()
    vs_mod.reset_for_tests()
    tools_mod.reset_for_tests()
    agents_mod.reset_for_tests()


# ---------------------------------------------------------------------------
# Semantic registry
# ---------------------------------------------------------------------------
def test_field_spec_rejects_unknown_type():
    with pytest.raises(SemanticError):
        FieldSpec(name="x", type="frobnicator", label_key="x")


def test_entity_spec_rejects_duplicate_fields():
    with pytest.raises(SemanticError):
        EntitySpec(
            key="crm.lead",
            label_key="crm.lead",
            fields=(
                FieldSpec(name="x", type="string", label_key="x"),
                FieldSpec(name="x", type="integer", label_key="x"),
            ),
        )


def test_register_entity_idempotent_on_equal():
    spec = EntitySpec(
        key="crm.lead",
        label_key="crm.lead.label",
        fields=(FieldSpec(name="name", type="string", label_key="lead.name"),),
    )
    register_entity(spec)
    register_entity(spec)  # idempotent
    different = EntitySpec(
        key="crm.lead",
        label_key="crm.lead.label",
        fields=(FieldSpec(name="email", type="string", label_key="lead.email"),),
    )
    with pytest.raises(SemanticError):
        register_entity(different)


# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------
def _noop(**_kwargs):
    return {"ok": True}


def test_action_validate_payload_rejects_unknown_and_missing():
    spec = ActionSpec(
        key="crm.lead.create",
        label_key="crm.lead.create",
        handler=_noop,
        params=(
            ActionParam(name="name", type="string", required=True),
            ActionParam(name="score", type="integer", required=False),
        ),
    )
    register_action(spec)
    with pytest.raises(ActionError):
        actions_mod.validate_payload(spec, {"score": 1})  # missing name
    with pytest.raises(ActionError):
        actions_mod.validate_payload(spec, {"name": "x", "bogus": 1})
    cleaned = actions_mod.validate_payload(spec, {"name": "x", "score": 5})
    assert cleaned == {"name": "x", "score": 5}


def test_register_action_rejects_different_handler():
    spec = ActionSpec(
        key="crm.lead.touch",
        label_key="crm.lead.touch",
        handler=_noop,
    )
    register_action(spec)

    def other(**_kw):
        return None

    dup = ActionSpec(
        key="crm.lead.touch",
        label_key="crm.lead.touch",
        handler=other,
    )
    with pytest.raises(ActionError):
        register_action(dup)


# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
def test_prompt_template_discovers_variables():
    tmpl = PromptTemplate(key="greet", template="Hello {name}, today is {day}.")
    assert tmpl.variables == ("day", "name")
    assert tmpl.render(name="Alice", day="Mon") == "Hello Alice, today is Mon."


def test_prompt_render_missing_variable():
    tmpl = register_prompt(PromptTemplate(key="x", template="hi {name}"))
    with pytest.raises(PromptError):
        tmpl.render()


# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
def test_echo_provider_returns_last_user_message():
    provider = EchoProvider()
    resp = provider.chat([Message(role="user", content="hello")])
    assert "hello" in resp.content
    assert resp.provider == "echo"


def test_scripted_provider_exhausts():
    provider = ScriptedProvider(replies=["one", "two"])
    register_provider(provider)
    assert provider.chat([Message(role="user", content="x")]).content == "one"
    assert provider.chat([Message(role="user", content="x")]).content == "two"
    with pytest.raises(ProviderError):
        provider.chat([Message(role="user", content="x")])


# ---------------------------------------------------------------------------
# Embeddings
# ---------------------------------------------------------------------------
def test_hash_embedder_identical_text_is_self_similar():
    e = HashEmbedder(dimensions=16)
    a = e.embed("hello world")
    b = e.embed("hello world")
    assert pytest.approx(cosine_similarity(a, b), abs=1e-9) == 1.0


def test_hash_embedder_different_text_lower_similarity():
    e = HashEmbedder(dimensions=16)
    sim = cosine_similarity(e.embed("hello world"), e.embed("zebra giraffe"))
    assert sim < 1.0


# ---------------------------------------------------------------------------
# Vector store
# ---------------------------------------------------------------------------
def test_vector_store_tenant_isolation_and_topk():
    store = InMemoryVectorStore()
    e = HashEmbedder(dimensions=8)
    store.upsert(
        tenant_id=1,
        collection="docs",
        records=[
            VectorRecord(id="a", embedding=e.embed("apple"), metadata={"t": "apple"}),
            VectorRecord(id="b", embedding=e.embed("banana"), metadata={"t": "banana"}),
        ],
    )
    store.upsert(
        tenant_id=2,
        collection="docs",
        records=[
            VectorRecord(id="c", embedding=e.embed("apple"), metadata={"t": "other"}),
        ],
    )
    matches = store.query(tenant_id=1, collection="docs", embedding=e.embed("apple"), top_k=1)
    assert len(matches) == 1
    assert matches[0].id == "a"  # tenant 2's "c" is invisible
    assert matches[0].score >= matches[0].score  # sanity


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
def test_tool_for_action_validates_payload():
    spec = ActionSpec(
        key="crm.lead.score",
        label_key="crm.lead.score",
        handler=lambda **kw: {"score": kw["value"] * 2},
        params=(ActionParam(name="value", type="integer", required=True),),
    )
    register_action(spec)
    tool = tool_for_action(spec)
    assert tools_mod.invoke_tool(tool.name, value=3) == {"score": 6}
    with pytest.raises(ActionError):
        tools_mod.invoke_tool(tool.name, bogus=1)


def test_tool_unknown_raises():
    with pytest.raises(ToolError):
        tools_mod.get_tool("does_not_exist")


# ---------------------------------------------------------------------------
# Memory
# ---------------------------------------------------------------------------
def test_conversation_memory_is_bounded():
    mem = ConversationMemory(max_entries=3)
    for i in range(5):
        mem.append("conv-1", memory_mod.MemoryEntry(role="user", content=str(i)))
    history = mem.history("conv-1")
    assert [m.content for m in history] == ["2", "3", "4"]


# ---------------------------------------------------------------------------
# Context builder
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_build_context_requires_tenant(alice):
    with pytest.raises(ContextError):
        build_context(tenant=None)


@pytest.mark.django_db
def test_build_context_populates_entities_and_recall(
    api_client, tenant_acme, acme_tree, alice, alice_membership,
):
    register_entity(
        EntitySpec(
            key="crm.lead",
            label_key="crm.lead",
            fields=(FieldSpec(name="name", type="string", label_key="lead.name"),),
        ),
    )
    e = embeddings_mod.get_embedder("hash")
    store = vs_mod.get_store("memory")
    store.upsert(
        tenant_id=tenant_acme.pk,
        collection="docs",
        records=[VectorRecord(id="r1", embedding=e.embed("apple"), metadata={"t": "apple"})],
    )
    api_client.force_login(alice)
    # Hit a real endpoint to populate request context, then call build_context
    # by invoking the agent endpoint below; the bare builder is exercised in
    # the services tests where we pass `tenant` explicitly.
    ctx = build_context(tenant=tenant_acme, entity_keys=("crm.lead",))
    assert ctx.tenant_id == tenant_acme.pk
    assert ctx.entities[0]["key"] == "crm.lead"


# ---------------------------------------------------------------------------
# Agent runner
# ---------------------------------------------------------------------------
def test_agent_runner_rejects_unlisted_tool():
    register_agent(
        AgentSpec(key="ag", label_key="ag.label", allowed_tools=("foo_bar",)),
    )
    with pytest.raises(AgentError):
        run_agent(
            "ag",
            user_input="hi",
            tool_calls=[{"name": "not_allowed", "payload": {}}],
        )


def test_agent_runner_invokes_tool_and_provider():
    spec = ActionSpec(
        key="x.y.add",
        label_key="x.y.add",
        handler=lambda **kw: {"sum": kw["a"] + kw["b"]},
        params=(
            ActionParam(name="a", type="integer", required=True),
            ActionParam(name="b", type="integer", required=True),
        ),
    )
    register_action(spec)
    tool = tool_for_action(spec)
    register_provider(ScriptedProvider(replies=["done"]))
    register_agent(
        AgentSpec(
            key="adder",
            label_key="adder.label",
            provider="scripted",
            allowed_tools=(tool.name,),
        ),
    )
    run = run_agent(
        "adder",
        user_input="please add",
        tool_calls=[{"name": tool.name, "payload": {"a": 2, "b": 3}}],
    )
    assert run.response.content == "done"
    assert run.tool_invocations[0]["result"] == {"sum": 5}


# ---------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_create_suggestion_writes_row(tenant_acme, acme_tree, alice):
    s = services_mod.create_suggestion(
        tenant_acme,
        title="Promote lead",
        kind=SuggestionKind.INSIGHT,
        summary="Score is high",
        created_by_id=alice.pk,
    )
    assert s.pk is not None
    assert s.status == SuggestionStatus.PENDING


@pytest.mark.django_db
def test_execute_action_direct_success(tenant_acme, acme_tree, alice):
    spec = ActionSpec(
        key="x.y.echo",
        label_key="x.y.echo",
        handler=lambda **kw: {"echoed": kw["msg"]},
        params=(ActionParam(name="msg", type="string", required=True),),
    )
    register_action(spec)
    log = services_mod.execute_action(
        tenant_acme,
        "x.y.echo",
        payload={"msg": "hi"},
        actor_id=alice.pk,
    )
    assert log.status == ActionLogStatus.SUCCEEDED
    assert log.result == {"echoed": "hi"}


@pytest.mark.django_db
def test_execute_action_requires_approval_creates_pending_log(
    tenant_acme, acme_tree, alice,
):
    spec = ActionSpec(
        key="x.y.dangerous",
        label_key="x.y.dangerous",
        handler=lambda **kw: {"ok": True},
        requires_approval=True,
    )
    register_action(spec)
    log = services_mod.execute_action(
        tenant_acme, "x.y.dangerous", payload={}, actor_id=alice.pk,
    )
    assert log.status == ActionLogStatus.PENDING_APPROVAL


@pytest.mark.django_db
def test_decide_suggestion_approve_then_execute_marks_applied(
    tenant_acme, acme_tree, alice,
):
    spec = ActionSpec(
        key="x.y.apply",
        label_key="x.y.apply",
        handler=lambda **kw: {"applied": True},
        requires_approval=True,
    )
    register_action(spec)
    suggestion = services_mod.create_suggestion(
        tenant_acme,
        title="Apply it",
        kind=SuggestionKind.ACTION,
        proposed_action="x.y.apply",
        created_by_id=alice.pk,
    )
    approval = services_mod.decide_suggestion(
        tenant_acme,
        str(suggestion.public_id),
        decision=ApprovalDecision.APPROVE,
        actor_id=alice.pk,
    )
    assert approval.decision == ApprovalDecision.APPROVE
    suggestion.refresh_from_db()
    assert suggestion.status == SuggestionStatus.APPROVED
    log = services_mod.execute_action(
        tenant_acme,
        "x.y.apply",
        payload={},
        actor_id=alice.pk,
        suggestion_id=str(suggestion.public_id),
    )
    assert log.status == ActionLogStatus.SUCCEEDED
    suggestion.refresh_from_db()
    assert suggestion.status == SuggestionStatus.APPLIED


@pytest.mark.django_db
def test_decide_suggestion_reject(tenant_acme, acme_tree, alice):
    s = services_mod.create_suggestion(tenant_acme, title="x", created_by_id=alice.pk)
    services_mod.decide_suggestion(
        tenant_acme,
        str(s.public_id),
        decision=ApprovalDecision.REJECT,
        actor_id=alice.pk,
    )
    s.refresh_from_db()
    assert s.status == SuggestionStatus.REJECTED


# ---------------------------------------------------------------------------
# HTTP API
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_action_execute_endpoint(
    api_client, tenant_acme, acme_tree, alice,
):
    spec = ActionSpec(
        key="x.y.http",
        label_key="x.y.http",
        handler=lambda **kw: {"ok": kw["v"]},
        params=(ActionParam(name="v", type="integer", required=True),),
    )
    register_action(spec)
    alice.is_superuser = True
    alice.save()
    api_client.force_login(alice)
    resp = api_client.post(
        reverse("platform_ai:action-execute", args=["x.y.http"]),
        data=json.dumps({"payload": {"v": 7}}),
        content_type="application/json",
        HTTP_X_TENANT=tenant_acme.slug,
    )
    assert resp.status_code == 200, resp.content
    body = resp.json()
    assert body["status"] == ActionLogStatus.SUCCEEDED
    assert body["result"] == {"ok": 7}


@pytest.mark.django_db
def test_suggestion_decide_endpoint(api_client, tenant_acme, acme_tree, alice):
    alice.is_superuser = True
    alice.save()
    s = services_mod.create_suggestion(tenant_acme, title="x", created_by_id=alice.pk)
    api_client.force_login(alice)
    resp = api_client.post(
        reverse("platform_ai:suggestion-decide", args=[str(s.public_id)]),
        data=json.dumps({"decision": "approve"}),
        content_type="application/json",
        HTTP_X_TENANT=tenant_acme.slug,
    )
    assert resp.status_code == 200, resp.content
    s.refresh_from_db()
    assert s.status == SuggestionStatus.APPROVED


@pytest.mark.django_db
def test_agent_run_endpoint(api_client, tenant_acme, acme_tree, alice):
    register_provider(ScriptedProvider(replies=["hi back"]))
    register_agent(AgentSpec(key="greeter", label_key="greeter.label", provider="scripted"))
    alice.is_superuser = True
    alice.save()
    api_client.force_login(alice)
    resp = api_client.post(
        reverse("platform_ai:agent-run", args=["greeter"]),
        data=json.dumps({"input": "hello"}),
        content_type="application/json",
        HTTP_X_TENANT=tenant_acme.slug,
    )
    assert resp.status_code == 200, resp.content
    assert resp.json()["response"]["content"] == "hi back"


@pytest.mark.django_db
def test_suggestions_audit_models_round_trip(tenant_acme, acme_tree, alice):
    s = services_mod.create_suggestion(tenant_acme, title="x", created_by_id=alice.pk)
    services_mod.decide_suggestion(
        tenant_acme, str(s.public_id),
        decision=ApprovalDecision.REJECT, actor_id=alice.pk,
    )
    assert AISuggestion.objects.filter(tenant=tenant_acme).count() == 1
    assert AIApproval.objects.filter(tenant=tenant_acme).count() == 1
    assert AIActionLog.objects.filter(tenant=tenant_acme).count() == 0
