"""Deciding whether a visitor's activity is worth a suggestion.

Deterministic rules and a database lookup, deliberately. This runs on every page
view of every visitor, so it must answer in tens of milliseconds; a model call
would take seconds and would spend the tenant's ai_tokens quota -- the same
budget the chatbot itself needs -- on visitors who never open the widget.

Neighbours come from the pairing graph (`servable_neighbours`), which is built
offline and already carries a real relationship (`pair_type`, `pair_score`)
rather than "same category". `related_products` (the `related_keys` column) is
the fallback for a tenant who has never run pairing -- it keeps that tenant no
worse off than before the graph existed, but it is not the primary source.

Nothing reachable from `decide()` calls a model. The `search` branch matches on
text (`match_products`, a plain ILIKE lookup) rather than on meaning -- it is
deliberately not `search_products`, which embeds the query and spends
ai_tokens quota. That embedding search is fine inside the chat tool, where a
model call is already in the loop and the visitor is waiting on a reply
anyway; it is not fine here, where the caller is an unauthenticated widget
firing on every page view. Do not swap this back to `search_products` to
"improve" match quality -- that would reintroduce a model call, and therefore
cost and latency, into a path that must stay a cheap lookup.

There is no cooldown in this version. Every qualifying event returns a
recommendation, which means a visitor moving through several pages is suggested
something on each one.
"""
import logging
from urllib.parse import parse_qsl, urlparse

from app.services.catalog.products import find_by_url, match_products, related_products, to_card
from app.services.chat.tools_settings import get_tool_settings
from app.services.pairing.queries import servable_neighbours

logger = logging.getLogger(__name__)

VALID_EVENTS = {"page_view", "dwell", "search",
                "add_to_cart", "cart_abandon", "checkout_start"}

# Below this, the visitor is still skimming.
DWELL_THRESHOLD_SECONDS = 30

MESSAGES = {
    "dwell": "Still deciding? You might also like these.",
    "search": "Here's what matched your search.",
}


# Search pages put the term in the query string under one of these names. Reading
# it from the URL keeps the frontend contract to four fields instead of five.
_SEARCH_PARAMS = ("q", "s", "query", "search", "keyword", "k")


def search_query_from_url(url: str) -> str:
    """The search term a search results page carries in its own URL."""
    try:
        params = dict(parse_qsl(urlparse(url).query, keep_blank_values=False))
    except Exception:
        return ""
    for name in _SEARCH_PARAMS:
        if params.get(name):
            return params[name].strip()
    return ""


def _nothing(reason: str) -> dict:
    return {"recommend": False, "reason": reason}


def decide(tenant_id: str, event: str, page: dict) -> dict:
    """Whether to suggest anything, and what. Never raises."""
    try:
        features = get_tool_settings(tenant_id)["features"]
        if not features.get("product_recommendation", True):
            return _nothing("feature_off")

        current = find_by_url(tenant_id, page.get("url") or "")
        if not current:
            return _nothing("page_unknown")

        if event == "dwell":
            if (page.get("dwell_seconds") or 0) < DWELL_THRESHOLD_SECONDS:
                return _nothing("no_rule_matched")
        elif event != "search":
            # page_view, add_to_cart, cart_abandon and checkout_start have no
            # rule yet. They are accepted so the contract need not change later.
            return _nothing("no_rule_matched")

        if event == "search":
            query = search_query_from_url(page.get("url") or "")
            if not query:
                return _nothing("no_rule_matched")
            candidates = match_products(tenant_id, query, limit=4)
        else:
            # The graph first; related_keys only for tenants who have never run
            # pairing, so this change is safe to deploy before every tenant has
            # been paired and rollback is one constant rather than a revert.
            candidates = servable_neighbours(tenant_id, current["product_key"],
                                             limit=4)
            if not candidates:
                candidates = related_products(tenant_id, current, limit=4)

        # Never suggest the page the visitor is already looking at.
        others = [r for r in candidates
                  if r.get("product_key") != current.get("product_key")][:3]
        if not others:
            return _nothing("no_match")

        return {"recommend": True,
                "message": MESSAGES.get(event, MESSAGES["dwell"]),
                "products": [_attributed(r) for r in others]}
    except Exception as ex:
        # A suggestion the visitor never asked for must never surface an error.
        logger.error(f"Recommendation decision failed for {tenant_id}: {ex}", exc_info=True)
        return _nothing("no_match")


def _attributed(row: dict) -> dict:
    """to_card is shared with the chat path and must not learn about pairing, so
    the attribution is attached here. Phase 5 needs it to work out which pair
    types earn their place."""
    card = to_card(row)
    if row.get("pair_type"):
        card["pair_type"] = row["pair_type"]
        card["pair_score"] = row.get("pair_score")
    return card
