"""
LLM logic used exclusively by the talkback pipeline (moved from askme_open_agent.askme_base).

Callers should use the functions re-exported from talkback_orchestrator; this module keeps
AskmeBase focused on SQL/chart/general prompts while talkback routing stays colocated.
"""
from __future__ import annotations

import json
import logging
import re
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

# Stable token for intent_routing when user wants prior table vs uploaded spreadsheet/file.
PRIOR_DF_FOLLOWUP_FILE_RECONCILE = "file_reconcile"

DEFAULT_FILE_RECONCILE_CLARIFICATION_MESSAGE = (
    "To compare or reconcile your last result with a spreadsheet, use POST /askme/reconcile "
    "(form fields prev_df, question, user_id, agent_id, file) or POST /askme/talkback-ask-question "
    "with multipart/form-data: same field names as talkback JSON (include prior rows in `df`, "
    "`agent_id`, `user_id`, …) plus part `file`. "
    "That runs the reconcile merge — not the document @file agent. "
    "Continue in Talkback with the merged table in `df` for follow-up questions."
)

def apply_implicit_web_askme_prefix(
    question: Optional[str],
    *,
    web_active: Optional[bool] = None,
    askme_active: Optional[bool] = None,
) -> str:
    """
    Legacy hook: previously prepended web/askme markers from keyword heuristics.
    Markers must be supplied explicitly by the client (e.g. talkback-ask-question); this returns the
    question unchanged (aside from stripping). ``web_active`` / ``askme_active`` are ignored.
    """
    _ = (web_active, askme_active)
    if not question or not str(question).strip():
        return (question or "").strip()
    return str(question).strip()


# --- Prior-turn routing heuristics (for suggest_intent_and_agents prompt + post-process) ---

_ROUTING_CURRENT_MSG_MARKER = "Current user message:"
# Marker emitted by the clarification-reply branch of `_build_intent_routing_question`
# in the orchestrator. The text between this marker and the next blank line is the
# verbatim user reply (e.g. "Order Management"); everything after the blank line is
# system instructions to the LLM (e.g. the IMPORTANT: ... block) and must NOT be
# treated as user content for keyword/name matching.
_ROUTING_CLARIFICATION_REPLY_MARKER = "The user's response (choosing agent(s)):"

_WEB_PRIOR_PHRASES: Tuple[str, ...] = (
    "search the web",
    "web search",
    "on the web",
    "from the web",
    "google",
    "internet",
    "benchmark",
    "benchmarks",
    "industry standard",
    "competitor",
    "competitors",
    "current events",
    "news",
    "analyst",
    "analysts",
    "regulatory",
    "macroeconomic",
    "macro",
    "external data",
    "look up online",
    "market outlook",
    "what do analysts",
    "search for",
    "online",
    "look up",
    "find online",
    "public data",
    "live data",
    "latest ",
    "current ",
    "today's",
    "real-time",
    "real time",
    "worldwide",
    "globally",
)

# High-signal phrases: with a prior table in context, these usually signal web-with-prior, not table-only analysis.
_WEB_PRIOR_STRONG_MARKERS: Tuple[str, ...] = (
    "stock market",
    "stock markets",
    "share price",
    "equities",
    "equity market",
    "nasdaq",
    "nyse",
    "dow jones",
    "stock price",
    "s&p",
    "s & p",
    "sp500",
    "s&p 500",
    "wall street",
    "interest rate",
    "interest rates",
    "federal reserve",
    "fed rate",
    "central bank",
    "gdp ",
    "cpi ",
    "inflation ",
    "unemployment",
    "crypto",
    "bitcoin",
    "forex",
    "currency market",
    "commodity",
    "commodities",
    "oil price",
    "gold price",
    "breaking news",
    "latest news",
    "earnings",
    "sec filing",
    "analyst rating",
    "economic outlook",
    "macro outlook",
)

_ASKME_PRIOR_PHRASES: Tuple[str, ...] = (
    "forecast",
    "predict",
    "prediction",
    "project",
    "projection",
    "extrapolate",
    "trend",
    "analyze",
    "analyse",
    "summary",
    "summarize",
    "explain",
    "insight",
    "anomaly",
    "correlation",
    "segment",
    "what if",
    "scenario",
    "yoy",
    "year over year",
    "mom",
    "interpret",
    "why is",
    "what does this mean",
)

_CONTINUITY_PHRASES: Tuple[str, ...] = (
    "same data",
    "this data",
    "these results",
    "last result",
    "previous result",
    "table above",
    "numbers above",
    "as above",
    "this table",
    "these rows",
    "that result",
    "the result above",
    "from the last",
    "previous answer",
    "the chart",
    "this query",
    "those numbers",
    "the data above",
)


def _routing_extract_current_user_utterance(full_question: str) -> str:
    fq = full_question or ""
    if _ROUTING_CURRENT_MSG_MARKER in fq:
        idx = fq.rfind(_ROUTING_CURRENT_MSG_MARKER)
        return fq[idx + len(_ROUTING_CURRENT_MSG_MARKER) :].strip()
    return fq.strip()


def _routing_has_prior_turn_block(full_question: str) -> bool:
    fq = (full_question or "").lower()
    return "prior conversation turn" in fq


def _routing_count_phrase_hits(text: str, phrases: Tuple[str, ...]) -> int:
    t = (text or "").lower()
    return sum(1 for p in phrases if p in t)


def _prior_df_build_heuristic_hint_block(full_question: str) -> str:
    if not _routing_has_prior_turn_block(full_question):
        return ""
    cur = _routing_extract_current_user_utterance(full_question)
    w = _routing_count_phrase_hits(cur, _WEB_PRIOR_PHRASES)
    ws = _routing_count_phrase_hits(cur, _WEB_PRIOR_STRONG_MARKERS)
    a = _routing_count_phrase_hits(cur, _ASKME_PRIOR_PHRASES)
    c = _routing_count_phrase_hits(cur, _CONTINUITY_PHRASES)
    return (
        "\nROUTING_SIGNALS (informational phrase counts only — do not pick agents from these alone):\n"
        f"- continuity≈{c}, on_table≈{a}, web_tone≈{w}, web_domain≈{ws}.\n"
        "- prior_df_followup in your JSON must be only none or file_reconcile (see main instructions).\n"
        "- Strong file-reconcile cue: prior table vs uploaded Excel/CSV/spreadsheet for compare or diff.\n"
        "- Strong fresh-query cue: new year/filter/entity with no upload tie → prior_df_followup none.\n"
    )


_EXPLICIT_NAME_MIN_LEN = 3


def _extract_user_utterance_for_explicit_match(full_question: str) -> str:
    """
    Return ONLY the user's literal utterance from a routing prompt.

    `_build_intent_routing_question` may wrap the user's question with system context:
      • Clarification-reply path: includes a "System clarification prompt: ... <agent A>, <agent B>"
        line that lists the competing agents. Naively scanning this for agent names would
        spuriously match every candidate from the disambiguation menu.
      • Prior-turn path: prefixes a "Q: <prior> / A: <answer>" block before the current message.

    For explicit-name matching we want only what the user actually typed:
      • Clarification reply → text between the reply marker and the next blank line.
      • Prior-turn block    → text after the "Current user message:" marker.
      • Plain question      → the full text.
    """
    fq = (full_question or "").strip()
    if not fq:
        return ""
    if _ROUTING_CLARIFICATION_REPLY_MARKER in fq:
        idx = fq.rfind(_ROUTING_CLARIFICATION_REPLY_MARKER)
        tail = fq[idx + len(_ROUTING_CLARIFICATION_REPLY_MARKER) :].lstrip()
        # The reply ends at the next blank line (instructions for the LLM follow).
        end = tail.find("\n\n")
        return (tail[:end] if end != -1 else tail).strip()
    if _ROUTING_CURRENT_MSG_MARKER in fq:
        idx = fq.rfind(_ROUTING_CURRENT_MSG_MARKER)
        return fq[idx + len(_ROUTING_CURRENT_MSG_MARKER) :].strip()
    return fq


def _explicit_agent_name_matches(
    question: str,
    agent_profiles: List[Dict[str, str]],
    allowed_ids: List[str],
) -> List[str]:
    """
    Return agent_ids whose ``agent_name`` appears as a whole-phrase substring inside ``question``.

    Match is case-insensitive on whitespace-normalized text and uses regex word boundaries so
    short tokens cannot trigger on substrings (e.g. ``ap`` will not match ``appliance``).
    Names shorter than ``_EXPLICIT_NAME_MIN_LEN`` characters are skipped to avoid false positives.
    Order of returned ids preserves the order in which the names first appear in the question;
    duplicates and names not in ``allowed_ids`` are filtered out.
    """
    if not question or not agent_profiles:
        return []
    norm_q = re.sub(r"\s+", " ", question.lower()).strip()
    if not norm_q:
        return []
    allowed_set = {str(x).strip() for x in allowed_ids if str(x).strip()}
    hits: List[Tuple[int, str]] = []
    seen_ids: set = set()
    for p in agent_profiles:
        aid = str(p.get("agent_id", "") or "").strip()
        if not aid or aid not in allowed_set or aid in seen_ids:
            continue
        aname = str(p.get("agent_name") or "").strip()
        if len(aname) < _EXPLICIT_NAME_MIN_LEN:
            continue
        norm_name = re.sub(r"\s+", " ", aname.lower()).strip()
        if not norm_name:
            continue
        # Word-boundary match on normalized name; re.escape handles any punctuation in the name.
        pattern = r"\b" + re.escape(norm_name) + r"\b"
        m = re.search(pattern, norm_q)
        if m:
            hits.append((m.start(), aid))
            seen_ids.add(aid)
    hits.sort(key=lambda t: t[0])
    return [aid for _, aid in hits]


def _apply_explicit_agent_name_override(
    out: Dict[str, Any],
    question: str,
    agent_profiles: List[Dict[str, str]],
    allowed_ids: List[str],
) -> Dict[str, Any]:
    """
    Hard-override LLM routing when the user explicitly names one or more agents.

    The prompt already asks the model to honor explicit names, but models occasionally
    ignore that rule and return ``ambiguous`` with multiple candidates. Restoring this
    override server-side guarantees the user is never re-prompted to pick an agent
    they already named in the question.

    - One name match → single-agent dispatch (intent kept if domain-typed; else ``sql_query``).
    - Two+ name matches → ``multi_agent`` dispatch with the matched ids.
    - Skips when the model already enabled file-reconcile / askme_analysis / web_enrichment
      (those are preserved exactly), or when no agent_name appears in the question.
    """
    if out.get("error"):
        return out
    prior = str(out.get("prior_df_followup", "none") or "none").strip().lower()
    if prior in (PRIOR_DF_FOLLOWUP_FILE_RECONCILE, "askme_analysis", "web_enrichment"):
        return out
    # Match against the user's verbatim utterance only. The wrapped routing prompt may
    # contain a "System clarification prompt: <agent A>, <agent B>, ..." line that lists
    # competing agents from a prior disambiguation; matching the full prompt would pick
    # up every candidate and incorrectly fan out to all of them when the user typed only
    # one (e.g. user replied "Order Management" → all 4 candidates were dispatched).
    user_utterance = _extract_user_utterance_for_explicit_match(question)
    matched = _explicit_agent_name_matches(user_utterance, agent_profiles, allowed_ids)
    if not matched:
        return out
    if len(matched) == 1:
        intent = str(out.get("intent", "") or "").strip().lower()
        if intent not in ("sql_query", "api", "document", "web"):
            intent = "sql_query"
        out["intent"] = intent
        out["suggested_agent_ids"] = matched
    else:
        out["intent"] = "multi_agent"
        out["suggested_agent_ids"] = matched
    out["needs_clarification"] = False
    out["clarification_message"] = None
    out["clarification_agent_ids"] = matched
    return out


def _prior_df_apply_post_heuristics(
    out: Dict[str, Any], full_question: str, allowed_ids: List[str]
) -> Dict[str, Any]:
    """
    Caller keeps model prior_df_followup to ``none`` or ``file_reconcile`` only. This function
    may then set further prior-row modes from markers in the raw utterance (server-side).
    Preserves ``file_reconcile`` when already set.
    """
    _ = allowed_ids
    if out.get("error"):
        return out
    cur = _routing_extract_current_user_utterance(full_question)
    ql = cur.lower()

    if "@web" in ql:
        out["prior_df_followup"] = "web_enrichment"
        out["suggested_agent_ids"] = []
        out["needs_clarification"] = False
        out["misrouted_web"] = False
        intent = str(out.get("intent", "") or "").strip().lower()
        if intent in ("sql_query", "api", "web", "ambiguous"):
            out["intent"] = "other"
        return out
    if "@askme" in ql and "@web" not in ql:
        out["prior_df_followup"] = "askme_analysis"
        out["suggested_agent_ids"] = []
        out["needs_clarification"] = False
        out["misrouted_web"] = False
        intent = str(out.get("intent", "") or "").strip().lower()
        if intent in ("sql_query", "api", "ambiguous", "web"):
            out["intent"] = "other"
        return out

    prior = str(out.get("prior_df_followup", "none") or "none").strip().lower()
    if prior == PRIOR_DF_FOLLOWUP_FILE_RECONCILE:
        return out
    if prior in ("askme_analysis", "web_enrichment"):
        out["prior_df_followup"] = "none"
    return out


def _talkback_agent_log(agent: Any, message: str, title: str = "Info") -> None:
    log_fn = getattr(agent, "log", None)
    if callable(log_fn):
        log_fn(message, title)
    else:
        logger.info("%s: %s", title, message)


def _normalize_intent_routing_for_clarification(
    out: Dict[str, Any], allowed_ids: List[str]
) -> Dict[str, Any]:
    if out.get("error"):
        return out
    prior = str(out.get("prior_df_followup", "none")).lower()
    if prior in ("askme_analysis", "web_enrichment"):
        out["suggested_agent_ids"] = []
        out["needs_clarification"] = False
        out["clarification_message"] = None
        return out
    if prior == PRIOR_DF_FOLLOWUP_FILE_RECONCILE:
        return out
    if out.get("needs_clarification"):
        return out
    suggested = out.get("suggested_agent_ids") or []
    intent = str(out.get("intent", "")).strip().lower()
    aid_set = set(allowed_ids)
    if not suggested:
        out["needs_clarification"] = True
        if not out.get("clarification_message"):
            out["clarification_message"] = (
                "Could you specify which data source or topic your question refers to, "
                "or rephrase so we can pick the right agent?"
            )
        return out
    suggested_set = {str(x) for x in suggested}
    if (
        len(allowed_ids) > 1
        and suggested_set == aid_set
        and intent != "multi_agent"
    ):
        out["needs_clarification"] = True
        if not out.get("clarification_message"):
            out["clarification_message"] = (
                "Your question could apply to several agents. Which system or dataset should we use?"
            )
    elif (
        intent != "multi_agent"
        and len(allowed_ids) > 1
        and len(suggested) > 1
    ):
        out["needs_clarification"] = True
        if not out.get("clarification_message"):
            out["clarification_message"] = (
                "Your question could apply to several agents. Which system or dataset should we use?"
            )
    return out


def _ensure_clarification_agent_ids(
    out: Dict[str, Any], allowed_ids: List[str]
) -> Dict[str, Any]:
    if out.get("error"):
        return out
    prior = str(out.get("prior_df_followup", "none")).lower()
    if prior in ("askme_analysis", "web_enrichment", PRIOR_DF_FOLLOWUP_FILE_RECONCILE):
        out.pop("clarification_agent_ids", None)
        return out
    if not out.get("needs_clarification"):
        out.pop("clarification_agent_ids", None)
        return out
    allowed_set = set(allowed_ids)
    existing = out.get("clarification_agent_ids")
    if isinstance(existing, str):
        existing = [existing]
    elif not isinstance(existing, list):
        existing = []
    cleaned = [str(x) for x in existing if str(x) in allowed_set]
    if cleaned:
        out["clarification_agent_ids"] = cleaned
        return out
    suggested = out.get("suggested_agent_ids") or []
    if len(suggested) >= 1:
        # Keep clarification candidates explicit even for single-agent
        # confirm prompts ("I think X might help — should I proceed?").
        out["clarification_agent_ids"] = [str(x) for x in suggested if str(x) in allowed_set]
    return out


def _ensure_file_reconcile_routing_shape(out: Dict[str, Any]) -> Dict[str, Any]:
    if out.get("error"):
        return out
    if str(out.get("prior_df_followup", "none")).lower() != PRIOR_DF_FOLLOWUP_FILE_RECONCILE:
        return out
    out["suggested_agent_ids"] = []
    out["needs_clarification"] = True
    out.pop("clarification_agent_ids", None)
    msg = out.get("clarification_message")
    if not msg or not str(msg).strip():
        out["clarification_message"] = DEFAULT_FILE_RECONCILE_CLARIFICATION_MESSAGE
    intent = str(out.get("intent", "")).strip().lower()
    if intent in ("sql_query", "api", "web", "ambiguous", "multi_agent"):
        out["intent"] = "other"
    return out


def _maybe_nudge_file_reconcile_keyword_heuristic(
    full_question: str, out: Dict[str, Any]
) -> None:
    """
    If the model left prior_df_followup as none but the user clearly refers to a file/compare
    against the prior turn, set file_reconcile (conservative: requires prior-turn block).
    Mutates out in place.
    """
    if out.get("error"):
        return
    if str(out.get("prior_df_followup", "none")).lower() != "none":
        return
    if not _routing_has_prior_turn_block(full_question):
        return
    cur = _routing_extract_current_user_utterance(full_question).lower()
    file_ref = any(
        x in cur
        for x in (
            "upload",
            "uploaded",
            " my file",
            "spreadsheet",
            "excel",
            ".xlsx",
            "xlsx",
            ".csv",
            " csv ",
            "attachment",
        )
    )
    reconcile_sem = any(
        x in cur for x in ("reconcile", "reconciliation", "variance", "discrepancy", "mismatch")
    )
    compare_sem = "compare" in cur or "comparison" in cur or "match up" in cur or "match this" in cur
    diff_sem = re.search(r"\bdiff\b", cur) is not None or "difference" in cur
    if not file_ref:
        return
    if reconcile_sem or compare_sem or diff_sem:
        out["prior_df_followup"] = PRIOR_DF_FOLLOWUP_FILE_RECONCILE


def _maybe_nudge_web_enrichment_keyword_heuristic(
    full_question: str, out: Dict[str, Any]
) -> None:
    """Disabled: server pins web-with-prior from explicit markers in the question."""
    _ = (full_question, out)
    return


def talkback_build_single_agent_routing_text(cfg: Dict[str, Any], _agent_id: str) -> str:
    if not cfg:
        return "[Agent configuration not found]"
    desc = (cfg.get("description") or "").strip()
    if desc:
        return desc
    raw = (cfg.get("traning_data") or "").strip()
    if raw:
        return raw
    return "[No description or training data for this agent]"


def talkback_build_agent_profiles_for_routing(agent_ids: List[str]) -> List[Dict[str, str]]:
    from app.services.agent_config import get_agent_config

    profiles: List[Dict[str, str]] = []
    for aid in agent_ids:
        cfg = get_agent_config(agent_id=str(aid))
        if not cfg:
            profiles.append(
                {
                    "agent_id": str(aid),
                    "agent_name": str(aid),
                    "training_text": "[Agent configuration not found]",
                }
            )
            continue
        name = cfg.get("agent_name") or str(aid)
        text = talkback_build_single_agent_routing_text(cfg, str(cfg.get("agent_id", aid)))
        profiles.append(
            {
                "agent_id": str(cfg.get("agent_id", aid)),
                "agent_name": str(name),
                "training_text": text,
            }
        )
    return profiles


def _compact_turn_for_context_relevance(t: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    """Normalize a persisted talkback history dict for the context-relation classifier."""
    q = str(t.get("question", "")).strip()
    if not q:
        return None
    ql = q.lower()
    if ql.startswith("clarification response:"):
        inner = q.split(":", 1)[1].strip() if ":" in q else ""
        if not inner:
            return None
        return {
            "kind": "user_reply_after_system_clarification",
            "user_message": inner,
            "assistant_answer_preview": (str(t.get("answer", "")) or "")[:800],
        }
    row: Dict[str, Any] = {
        "kind": "turn",
        "user_question": q,
        "assistant_answer_preview": (str(t.get("answer", "")) or "")[:800],
    }
    meta = t.get("answer_meta")
    if isinstance(meta, dict):
        if meta.get("needs_clarification"):
            row["system_turn"] = "agent_or_source_disambiguation"
            row["note"] = (
                "Assistant did not return data; it asked the user to choose an agent, "
                "database, or data source. The analytical question is still pending."
            )
        cids = meta.get("clarification_agent_ids")
        if isinstance(cids, list) and cids:
            row["clarification_agent_ids"] = [str(x) for x in cids[:12]]
        tt = meta.get("turn_type")
        if tt:
            row["turn_type"] = str(tt)
        intent = meta.get("intent")
        if intent:
            row["routing_intent"] = str(intent)
    return row


def talkback_is_related_to_previous_context(
    agent: Any,
    previous_turns: List[Dict[str, Any]],
    current_question: str,
    **kwargs: Any,
) -> Dict[str, Any]:
    if not current_question or not current_question.strip():
        return {"is_related": False, "confidence": 1.0, "reason": "Empty question."}

    if not previous_turns:
        return {"is_related": False, "confidence": 1.0, "reason": "No prior context exists."}

    from app.services.talkback_orchestrator import _looks_like_clarification_reply

    if _looks_like_clarification_reply(current_question.strip()):
        for t in reversed(previous_turns[-8:]):
            if not isinstance(t, dict):
                continue
            qn = str(t.get("question", "")).strip().lower()
            if qn.startswith("clarification response:"):
                continue
            meta = t.get("answer_meta")
            if not isinstance(meta, dict):
                continue
            cids = meta.get("clarification_agent_ids")
            if isinstance(cids, list) and cids:
                return {
                    "is_related": True,
                    "confidence": 0.98,
                    "reason": "User is choosing agent(s) / source(s) from a prior disambiguation prompt.",
                }
            if meta.get("needs_clarification"):
                return {
                    "is_related": True,
                    "confidence": 0.97,
                    "reason": "User is replying to a pending clarification turn.",
                }
            # CHECK: If the user picks one of the proactive suggestions from the last turn
            opts = meta.get("talkback_options")
            if isinstance(opts, list) and opts:
                q_norm = re.sub(r"[^\w\s]", "", current_question.strip().lower())
                for opt in opts:
                    if not isinstance(opt, str):
                        continue
                    o_norm = re.sub(r"[^\w\s]", "", opt.strip().lower())
                    if q_norm == o_norm:
                        return {
                            "is_related": True,
                            "confidence": 0.99,
                            "reason": f"User is asking a suggested follow-up question: '{opt}'",
                        }

    compact_turns: List[Dict[str, Any]] = []
    for t in previous_turns:
        if not isinstance(t, dict):
            continue
        c = _compact_turn_for_context_relevance(t)
        if c is not None:
            compact_turns.append(c)

    if not compact_turns:
        return {"is_related": False, "confidence": 1.0, "reason": "No valid prior turns after filtering."}

    system_prompt = (
        "You are a context-dependency classifier. Given a CURRENT QUESTION and PREVIOUS CONVERSATION TURNS, "
        "decide whether the current question REQUIRES the prior context to be understood or answered correctly.\n\n"

        "Return ONLY valid JSON — no explanation, no markdown:\n"
        '{"is_related": true | false, "confidence": <0.0–1.0>, "reason": "<one short sentence>"}\n\n'

        "━━━ CORE RULE ━━━\n"
        "is_related = true  → the current question CANNOT be fully understood or answered without prior turns.\n"
        "is_related = false → the current question stands alone; prior turns add no required context.\n\n"

        "Topic similarity alone does NOT make a question related. Dependency on prior content does.\n\n"

        "━━━ MARK TRUE WHEN ━━━\n"
        "1. PRONOUNS / REFERENCES — uses 'it', 'that', 'those', 'the above', 'same', 'previous', 'now', 'then' etc. "
        "referring to prior content.\n"
        "2. REFINEMENT — asks to filter, sort, explain, expand, or modify a prior result "
        "('break that down by region', 'why is it so high?').\n"
        "3. DISAMBIGUATION COMPLETION — a prior turn asked the user to pick an agent, data source, or system "
        "(e.g. 'Order Management or SAP?'), and the current message: "
        "(a) names one of the options, (b) says 'the first one' / 'option 2', "
        "(c) repeats the original question with the missing choice now supplied, or "
        "(d) is a short fragment that only makes sense as answering that prompt.\n"
        "4. MISSING PARAMETER SUPPLY — a prior turn was incomplete (missing date range, entity, metric), "
        "and the current message clearly supplies that missing piece.\n"
        "5. SUGGESTED FOLLOW-UP — the current message matches or is very similar to one of the "
        "proactive suggested questions listed in the prior assistant turn.\n\n"

        "━━━ MARK FALSE WHEN ━━━\n"
        "1. SELF-CONTAINED — the question is fully answerable without any prior turn.\n"
        "2. NEW TOPIC — introduces a new metric, report, or entity not tied to completing a prior thread.\n"
        "3. SAME DOMAIN, DIFFERENT REQUEST — shares business context but starts a fresh analytical request.\n"
        "4. UNCERTAIN FRAGMENT — a short message whose link to the prior turn is unclear; default to false.\n\n"

        "━━━ DISAMBIGUATION SPECIAL CASE ━━━\n"
        "If any prior turn has system_turn = 'agent_or_source_disambiguation', the original request is UNRESOLVED "
        "until the user picks a source. Treat the thread as open. Any reply that plausibly resolves it → true.\n"
        "The same user question may repeat in history due to clarification re-display; treat as one open thread.\n\n"

        "━━━ INPUT FORMAT ━━━\n"
        "Prior turns include an optional 'kind' field:\n"
        "  • 'turn' — normal question + assistant reply.\n"
        "  • 'user_reply_after_system_clarification' — user message echoed after a system clarification prompt; "
        "the question text is under 'user_message'.\n\n"

        "━━━ CONFIDENCE GUIDE ━━━\n"
        "  0.95 — explicit pronoun/reference or clear disambiguation answer\n"
        "  0.80 — strong follow-up signal (refinement, parameter supply)\n"
        "  0.60 — plausible but not certain\n"
        "  0.40 — weak signal, leaning one way\n"
        "  0.20 — very little evidence, defaulting\n\n"

        "━━━ EXAMPLES ━━━\n"
        "Prior: 'Show me revenue by region for Q1' → Assistant: [table shown]\n"
        "Current: 'Now filter it to just North America'\n"
        '→ {"is_related": true, "confidence": 0.95, "reason": "\'it\' refers to the prior revenue table and adds a filter."}\n\n'

        "Prior: 'What is the order cancellation rate?' → System: 'Which source — Order Management or SAP?'\n"
        "Current: 'Order Management'\n"
        '→ {"is_related": true, "confidence": 0.95, "reason": "User is resolving the open source disambiguation."}\n\n'

        "Prior: 'Show me top 10 customers by revenue'\n"
        "Current: 'What is the average order value this quarter?'\n"
        '→ {"is_related": false, "confidence": 0.85, "reason": "New independent metric with no dependency on prior results."}\n\n'

        "Prior: 'Show me OTIF performance for last month' → Assistant: [result shown]\n"
        "Current: 'Show me fill rate performance for last month'\n"
        '→ {"is_related": false, "confidence": 0.80, "reason": "Same time frame but a completely new metric and independent request."}'
    )
    user_prompt = (
        "PREVIOUS TURNS (oldest -> newest):\n"
        + json.dumps(compact_turns, ensure_ascii=False, indent=2)
        + "\n\nCURRENT QUESTION:\n"
        + current_question.strip()
        + "\n\nClassify now."
    )

    message_log = [
        agent.system_message(system_prompt),
        agent.user_message(user_prompt),
    ]

    try:
        response = agent.submit_prompt(
            message_log,
            temperature=float(kwargs.get("temperature", 0.0)),
            max_tokens=kwargs.get("max_tokens", 150),
        )
        parsed = agent._extract_json_from_response(response)

        if not isinstance(parsed, dict):
            return {
                "is_related": False,
                "confidence": 0.4,
                "reason": "Parse failed; defaulting to unrelated to avoid stale context.",
            }

        raw_is_related = parsed.get("is_related", True)
        if isinstance(raw_is_related, bool):
            is_related = raw_is_related
        elif isinstance(raw_is_related, str):
            norm = raw_is_related.strip().lower()
            if norm in {"true", "1", "yes", "y"}:
                is_related = True
            elif norm in {"false", "0", "no", "n"}:
                is_related = False
            else:
                is_related = True
        elif isinstance(raw_is_related, (int, float)):
            is_related = bool(raw_is_related)
        else:
            is_related = True

        try:
            confidence = max(0.0, min(1.0, float(parsed.get("confidence", 0.5))))
        except (TypeError, ValueError):
            confidence = 0.5

        reason = str(parsed.get("reason", "")).strip()

        if is_related and confidence < 0.5:
            return {
                "is_related": False,
                "confidence": confidence,
                "reason": f"Low-confidence related verdict downgraded; treating as independent. Original: {reason}",
            }

        return {"is_related": is_related, "confidence": confidence, "reason": reason}

    except Exception as e:
        _talkback_agent_log(agent, f"is_related_to_previous_context failed: {e}", "ContextRelation")
        return {
            "is_related": False,
            "confidence": 0.4,
            "reason": "Exception; defaulting to unrelated to avoid stale context.",
        }


def talkback_generate_contextual_question_smart(
    agent: Any,
    previous_questions: list,
    current_question: str,
    **kwargs: Any,
) -> str:
    if not previous_questions:
        return current_question

    context = "\n".join([f"{i+1}. {q}" for i, q in enumerate(previous_questions)])

    prompt = [
        agent.system_message(
            "You expand shorthand into one self-contained question using PREVIOUS CONTEXT + CURRENT QUESTION.\n\n"
            "CORE RULE\n"
            "Output only the final question the user is asking (plain text, one short paragraph at most). "
            "If CURRENT QUESTION already stands alone, copy it verbatim (trimmed). If it only makes sense "
            "with prior turns, merge the prior analytical intent with CURRENT QUESTION's new constraints.\n\n"
            "━━━ REWRITE (merge context) WHEN ━━━\n"
            "1. CURRENT QUESTION is elliptical or only changes constraints (time period, region, segment, "
            "status, top-N, sort, filters, grouping) and a prior turn states the full report/metric/task.\n"
            "2. You can name the same entities/metrics as in PREVIOUS CONTEXT without guessing new facts.\n\n"
            "━━━ DO NOT REWRITE (return CURRENT QUESTION unchanged) WHEN ━━━\n"
            "1. CURRENT QUESTION is already a complete, standalone question.\n"
            "2. Resolving a pronoun or reference would require guessing details not stated in PREVIOUS CONTEXT.\n"
            "3. PREVIOUS CONTEXT is unrelated or contradictory; do not force a merge.\n\n"
            "RESOLUTION (when rewriting)\n"
            "- Anchor on the nearest prior turn whose topic matches CURRENT QUESTION; keep prior intent, "
            "apply only new constraints from CURRENT QUESTION.\n"
            "- Do not invent numbers, columns, systems, or business facts not present in context + current.\n\n"
            "NAMES / SOURCES (priority if this conflicts with generic 'be helpful')\n"
            "Never add agent names, product names, or data-source labels from PREVIOUS CONTEXT unless "
            "those exact words already appear in CURRENT QUESTION. Preserve wording the user already used "
            "in CURRENT QUESTION; do not strip it.\n\n"
            "OUTPUT\n"
            "Return ONLY the question text: no labels, no JSON, no markdown, no preamble or explanation.\n\n"
            "EXAMPLES (format: Prior lines numbered as in input → Current → your output)\n"
            "Prior: 1. Show monthly sales for 2024\n"
            "Current: now for 2025\n"
            "→ Show monthly sales for 2025.\n\n"
            "Prior: 1. Total revenue by region for Q3\n"
            "Current: Break that down by product line instead\n"
            "→ Show total revenue by product line for Q3.\n\n"
            "Prior: 1. What is our headcount?\n"
            "Current: List open purchase orders over $10k\n"
            "→ List open purchase orders over $10k.\n\n"
            "Prior: 1. Clarification response: use Order Management\n"
            "2. Monthly sales report 2024\n"
            "Current: for 2025\n"
            "→ Monthly sales report for 2025.\n\n"
            "Prior: 1. Revenue by country 2024\n"
            "Current: same but only EMEA and cap at top 5\n"
            "→ Show revenue by country for 2024 limited to EMEA, top 5 countries by revenue.\n"
        ),
        agent.user_message(
            f"PREVIOUS CONTEXT:\n{context}\n\n"
            f"CURRENT QUESTION:\n{current_question}\n\n"
            "Return only the final question (one line or one paragraph of question text, no other text)."
        ),
    ]

    return agent.submit_prompt(prompt, **kwargs)


def talkback_merge_captured_question_time_fragment(
    service: Any,
    captured_question: str,
    time_fragment: str,
    **kwargs: Any,
) -> str:
    """
    Merge the original question (no time scope) with a short time-only follow-up
    (e.g. "for 2024", "last quarter") using the same contextual expansion model as
    :func:`talkback_generate_contextual_question_smart` / ``generate_contextual_question`` in AskmeBase.
    """
    from app.services.askme_service import UPDATED_MODULES_AVAILABLE

    c = (captured_question or "").strip()
    f = (time_fragment or "").strip()
    if not c:
        return f
    if not f:
        return c
    if not UPDATED_MODULES_AVAILABLE:
        return f"{c} {f}".strip()
    try:
        agent = service._ensure_agent()
        out = talkback_generate_contextual_question_smart(agent, [c], f, **kwargs)
        merged = (out or "").strip()
        return merged if merged else f"{c} {f}".strip()
    except Exception as e:
        logger.warning("talkback_merge_captured_question_time_fragment failed: %s", e)
        return f"{c} {f}".strip()


# Broad-scope processing hint (talkback): intent-based, single-step clarification before agent routing.
BROAD_SCOPE_PROCESSING_CONSENT_TYPE = "broad_scope_processing"
BROAD_SCOPE_CLARIFICATION_MESSAGE = (
    "This query may take longer to process due to its broad scope. "
    "Do you want to mention the time period or proceed with the current query?"
)


def talkback_is_broad_or_underconstrained_query_impl(
    agent: Any,
    question: str,
    **kwargs: Any,
) -> bool:
    q = (question or "").strip()
    if not q:
        return False
    system = (
        "You classify whether a user request requires a time period but one is not provided.\n\n"
        "Return ONLY valid JSON: {\"broad\": true|false}\n\n"
        "broad=true when the request falls into this category:\n"
        "  1. MISSING TIME PERIOD — the user asks for data that usually requires a time period (e.g., sales, revenue, metrics, trends) but has NOT supplied any time span (year, month, 'last week', 'current quarter', etc.).\n\n"
        "broad=false when the request already contains a time period, or when the request naturally does not require a time period.\n\n"
        "Decide from general task shape and wording only. Do not use domain-specific keyword lists."
    )
    user = f"USER_REQUEST:\n{q}\n\nReturn JSON only."
    try:
        response = agent.submit_prompt(
            [agent.system_message(system), agent.user_message(user)],
            temperature=float(kwargs.get("temperature", 0.0)),
            max_tokens=int(kwargs.get("max_tokens", 80)),
            use_session=bool(kwargs.get("use_session", False)),
            session_context=kwargs.get("session_context") or {},
        )
        parsed = agent._extract_json_from_response(response)
        if isinstance(parsed, dict):
            b = parsed.get("broad", False)
            if isinstance(b, str):
                b = b.strip().lower() in {"true", "1", "yes", "y"}
            return bool(b)
    except Exception as e:
        logger.warning("talkback_is_broad_or_underconstrained_query_impl failed: %s", e)
    return False


def talkback_is_broad_or_underconstrained_query(
    service: Any,
    question: str,
    **kwargs: Any,
) -> bool:
    from app.services.askme_service import UPDATED_MODULES_AVAILABLE

    if not UPDATED_MODULES_AVAILABLE:
        return False
    try:
        agent = service._ensure_agent()
        return talkback_is_broad_or_underconstrained_query_impl(agent, question, **kwargs)
    except Exception as e:
        logger.warning("talkback_is_broad_or_underconstrained_query failed: %s", e)
        return False


def talkback_followup_is_time_expression_only_impl(
    agent: Any,
    text: str,
    **kwargs: Any,
) -> bool:
    t = (text or "").strip()
    if not t or t.strip().lower() == "proceed":
        return False
    system = (
        "You classify a single user reply.\n\n"
        "Return ONLY valid JSON: {\"time_only\": true|false}\n\n"
        "time_only=true ONLY if the message is solely a calendar/time window or period expression and "
        "does NOT add a new analytical task, metric, entity, or report instruction.\n"
        "time_only=false for a full or partial question, a new topic, or mixed content.\n\n"
        "Use general language understanding only; do not match against fixed keyword lists."
    )
    user = f"USER_REPLY:\n{t}\n\nReturn JSON only."
    try:
        response = agent.submit_prompt(
            [agent.system_message(system), agent.user_message(user)],
            temperature=float(kwargs.get("temperature", 0.0)),
            max_tokens=int(kwargs.get("max_tokens", 80)),
            use_session=bool(kwargs.get("use_session", False)),
            session_context=kwargs.get("session_context") or {},
        )
        parsed = agent._extract_json_from_response(response)
        if isinstance(parsed, dict):
            to = parsed.get("time_only", False)
            if isinstance(to, str):
                to = to.strip().lower() in {"true", "1", "yes", "y"}
            return bool(to)
    except Exception as e:
        logger.warning("talkback_followup_is_time_expression_only_impl failed: %s", e)
    return False


def talkback_followup_is_time_expression_only(
    service: Any,
    text: str,
    **kwargs: Any,
) -> bool:
    from app.services.askme_service import UPDATED_MODULES_AVAILABLE

    if not UPDATED_MODULES_AVAILABLE:
        return False
    try:
        agent = service._ensure_agent()
        return talkback_followup_is_time_expression_only_impl(agent, text, **kwargs)
    except Exception as e:
        logger.warning("talkback_followup_is_time_expression_only failed: %s", e)
        return False


def talkback_suggest_intent_and_agents_impl(
    agent: Any,
    question: str,
    agent_profiles: List[Dict[str, str]],
    **kwargs: Any,
) -> Dict[str, Any]:
    if not agent_profiles:
        return {
            "intent": "other",
            "suggested_agent_ids": [],
            "needs_clarification": True,
            "clarification_message": "No agent training context available.",
            "prior_df_followup": "none",
            "misrouted_web": False,
        }

    allowed_ids = [
        str(p.get("agent_id", "")).strip()
        for p in agent_profiles
        if p.get("agent_id") is not None and str(p.get("agent_id", "")).strip()
    ]
    if not allowed_ids:
        return {
            "intent": "other",
            "suggested_agent_ids": [],
            "needs_clarification": True,
            "clarification_message": "No valid agent_id in profile list.",
            "prior_df_followup": "none",
            "misrouted_web": False,
        }

    sections: List[str] = []
    for i, p in enumerate(agent_profiles, 1):
        aid = str(p.get("agent_id", "")).strip()
        aname = (p.get("agent_name") or aid).strip()
        ttext = (p.get("training_text") or "").strip()
        sections.append(
            f"### Agent {i}\n"
            f"- agent_id: {aid}\n"
            f"- agent_name: {aname}\n"
            f"- training_context (DDL/schema and/or registered API list — API-only agents may have no DDL):\n{ttext}\n"
        )
    blocks = "\n".join(sections)
    allowed_json = json.dumps(allowed_ids)
    routing_hint_block = _prior_df_build_heuristic_hint_block(question)
    prompt_text = f"""HIGHEST PRIORITY — EXPLICIT AGENT NAME OVERRIDE
Before any scoring, scan USER QUESTION for any agent_name from PER-AGENT TRAINING CONTEXT.
Match is case-insensitive and matches whether the name appears with prepositions
("from order management", "in payables", "via SAP", "using JDE", "on Avalon Coach")
or bare ("order management report 2024", "payables aging").
- Exactly one agent_name appears → suggested_agent_ids = [that agent_id];
  needs_clarification = false; clarification_message = null;
  intent = sql_query|api|document|web as fits the question.
- Two or more distinct agent_names appear → suggested_agent_ids = [those agent_ids];
  intent = multi_agent; needs_clarification = false; clarification_message = null.
- No agent_name appears → continue to CORE RULE / scoring below.
When this override fires, do NOT ask for clarification, do NOT add other agents to
suggested_agent_ids, do NOT return ambiguous. Go straight to OUTPUT.

CORE RULE
Score every agent. Return all capable candidates. Never stop at the first match.
Return exactly one JSON object — no markdown fences, no text before or after.

USER QUESTION:
{question}{routing_hint_block}

ALLOWED_AGENT_IDS (use exact strings only):
{allowed_json}

PER-AGENT TRAINING CONTEXT:
{blocks}

━━━ STEP 1 — EXPLICIT AGENT NAME (HARD OVERRIDE) ━━━
Recheck the rule from HIGHEST PRIORITY at the top of this prompt. If any agent_name
from PER-AGENT TRAINING CONTEXT appears (case-insensitive) inside USER QUESTION,
return immediately per that rule. Skip Steps 2–4. Go directly to OUTPUT.

━━━ STEP 2 — SCORE EVERY AGENT ━━━
For each agent in ALLOWED_AGENT_IDS, assign a relevance score independently:

  RELEVANT — ANY of:
    • Training context description clearly matches the question topic, OR
    • DDL/schema has ≥2 distinct column/table names that map to question keywords, OR
    • Registered API names contain ≥2 distinct matches to question keywords.

  POSSIBLE — ANY of:
    • One indirect keyword overlap with description, schema, or API names.

  NONE — No meaningful overlap whatsoever.

Score every agent. Do not stop early. Do not let one strong match prevent you from scoring others.

━━━ STEP 3 — COLLECT CANDIDATES ━━━
candidate_agent_ids = all agents scored RELEVANT or POSSIBLE, in this order:
  1. All RELEVANT agents first (sorted by agent_name alphabetically)
  2. All POSSIBLE agents after (sorted by agent_name alphabetically)

This list must always be complete. Never drop an agent that scored RELEVANT or POSSIBLE.

━━━ STEP 4 — DECISION ━━━

Case A — Zero candidates (all NONE):
  intent = ambiguous; suggested_agent_ids = []; needs_clarification = true;
  clarification_message = ask user to clarify what data/system they mean.

Case B — Exactly one RELEVANT, zero POSSIBLE:
  intent = sql_query|api|document|web as fits; suggested_agent_ids = [that id];
  needs_clarification = false; clarification_message = null.

Case C — Two+ RELEVANT, or one+ RELEVANT and one+ POSSIBLE, or two+ POSSIBLE:
  intent = ambiguous; suggested_agent_ids = candidate_agent_ids (full list, RELEVANT first);
  needs_clarification = true;
  clarification_message = "Multiple sources can answer this: <comma-separated agent_names>. Which should I use?"

Case D — Exactly one POSSIBLE, zero RELEVANT:
  intent = ambiguous; suggested_agent_ids = [that id]; needs_clarification = true;
  clarification_message = "I think <agent_name> might help — should I proceed with that?"

━━━ SPECIAL RULES ━━━

prior_df_followup:
  "file_reconcile" — user explicitly links a prior tabular result to an uploaded file for reconcile/compare/diff.
  "none" — everything else. Never output any other value.

misrouted_web:
  true — user says web/search/internet but is clearly asking for structured data (SQL/API pull).
  When true: prior_df_followup = "none", intent = sql_query or api, apply normal candidate scoring.

multi_agent intent:
  Only when user explicitly asks to combine/compare two named systems in one answer.
  Broad questions that loosely fit many agents → ambiguous, NOT multi_agent.

Greetings / meta / non-data:
  intent = other; suggested_agent_ids = []; needs_clarification = true; clarification_message = null.

━━━ INTENTS ━━━
sql_query   — structured DB query; one agent's schema clearly fits.
api         — one agent's HTTP APIs clearly fit.
document    — files/documents.
web         — answer requires live web search (and misrouted_web is false).
multi_agent — user explicitly named multiple systems and asked for combined answer.
ambiguous   — multiple candidates, or zero candidates, or cannot determine.
other       — greetings, meta, non-data questions.

━━━ OUTPUT (JSON only) ━━━
{{
  "intent": "<sql_query|document|api|web|multi_agent|ambiguous|other>",
  "suggested_agent_ids": ["<ordered candidate list — RELEVANT first, then POSSIBLE>"],
  "needs_clarification": <true|false>,
  "clarification_message": <null|string>,
  "prior_df_followup": "<none|file_reconcile>",
  "misrouted_web": <true|false>
}}

Rules:
- suggested_agent_ids must only contain ids from ALLOWED_AGENT_IDS.
- suggested_agent_ids must include ALL agents scored RELEVANT or POSSIBLE — never truncate.
- clarification_agent_ids field is removed — candidates are now in suggested_agent_ids directly.
- For sql_query|api|document|web: suggested_agent_ids has exactly one id (Case B only).
- If misrouted_web = true → prior_df_followup = "none".

━━━ EXAMPLES ━━━

User: "Use Order Management: open orders this week" →
{{"intent":"sql_query","suggested_agent_ids":["<om_id>"],"needs_clarification":false,"clarification_message":null,"prior_df_followup":"none","misrouted_web":false}}

User: "monthly sales report 2024 from order management" (explicit name → HARD OVERRIDE; do NOT ask for clarification even if other agents could also fit) →
{{"intent":"sql_query","suggested_agent_ids":["<om_id>"],"needs_clarification":false,"clarification_message":null,"prior_df_followup":"none","misrouted_web":false}}

User: "show open invoices in Payables" (explicit name → HARD OVERRIDE) →
{{"intent":"api","suggested_agent_ids":["<payables_id>"],"needs_clarification":false,"clarification_message":null,"prior_df_followup":"none","misrouted_web":false}}

User: "supplier aging" (Order Management and SAP both score RELEVANT) →
{{"intent":"ambiguous","suggested_agent_ids":["<om_id>","<sap_id>"],"needs_clarification":true,"clarification_message":"Multiple sources can answer this: Order Management, SAP. Which should I use?","prior_df_followup":"none","misrouted_web":false}}

User: "supplier aging" (Order Management scores RELEVANT, Logistics scores POSSIBLE) →
{{"intent":"ambiguous","suggested_agent_ids":["<om_id>","<logistics_id>"],"needs_clarification":true,"clarification_message":"Multiple sources can answer this: Order Management, Logistics. Which should I use?","prior_df_followup":"none","misrouted_web":false}}

User: "hi" →
{{"intent":"other","suggested_agent_ids":[],"needs_clarification":true,"clarification_message":null,"prior_df_followup":"none","misrouted_web":false}}

User: "Reconcile the prior result with the spreadsheet I uploaded" →
{{"intent":"other","suggested_agent_ids":[],"needs_clarification":true,"clarification_message":null,"prior_df_followup":"file_reconcile","misrouted_web":false}}

User: "Compare Order Management sales with JDE POs in one view" →
{{"intent":"multi_agent","suggested_agent_ids":["<om_id>","<jde_id>"],"needs_clarification":false,"clarification_message":null,"prior_df_followup":"none","misrouted_web":false}}
"""

    submit_kwargs = {
        k: v
        for k, v in kwargs.items()
        if k in ("temperature", "max_tokens", "stop", "use_session", "session_context")
    }
    temperature = float(submit_kwargs.get("temperature", 0.2))
    max_tokens = submit_kwargs.get("max_tokens", 800)

    message_log = [
        agent.system_message(
            "Routing model: obey the user message. Output a single JSON object only — valid JSON, "
            "no markdown code fences, no text before or after the object."
        ),
        agent.user_message(prompt_text),
    ]
    try:
        response = agent.submit_prompt(
            message_log,
            temperature=temperature,
            max_tokens=max_tokens,
            use_session=bool(submit_kwargs.get("use_session", False)),
            session_context=submit_kwargs.get("session_context") or {},
        )
        parsed = agent._extract_json_from_response(response)
        if not parsed or not isinstance(parsed, dict):
            return {
                "error": "parse_failed",
                "intent": "ambiguous",
                "suggested_agent_ids": [],
                "needs_clarification": True,
                "clarification_message": "Could not interpret routing response.",
                "prior_df_followup": "none",
                "misrouted_web": False,
                "raw_response": (response[:2000] if response else None),
            }

        raw_prior = str(parsed.get("prior_df_followup", "none") or "none").strip().lower()
        # Model may only return none | file_reconcile; other prior-row modes are applied in post_heuristics.
        if raw_prior not in ("none", PRIOR_DF_FOLLOWUP_FILE_RECONCILE):
            raw_prior = "none"
        misrouted = bool(parsed.get("misrouted_web", False))
        if misrouted:
            raw_prior = "none"

        out: Dict[str, Any] = {
            "intent": str(parsed.get("intent", "other")),
            "suggested_agent_ids": parsed.get("suggested_agent_ids") or [],
            "needs_clarification": bool(parsed.get("needs_clarification", False)),
            "clarification_message": parsed.get("clarification_message"),
            "prior_df_followup": raw_prior,
            "misrouted_web": misrouted,
        }
        if isinstance(out["suggested_agent_ids"], str):
            out["suggested_agent_ids"] = [out["suggested_agent_ids"]]
        elif not isinstance(out["suggested_agent_ids"], list):
            out["suggested_agent_ids"] = []

        allowed_set = set(allowed_ids)
        out["suggested_agent_ids"] = [
            str(x) for x in out["suggested_agent_ids"] if str(x) in allowed_set
        ]

        ca_raw = parsed.get("clarification_agent_ids")
        if isinstance(ca_raw, str):
            ca_raw = [ca_raw]
        elif not isinstance(ca_raw, list):
            ca_raw = []
        out["clarification_agent_ids"] = [
            str(x) for x in ca_raw if str(x) in allowed_set
        ]

        _maybe_nudge_file_reconcile_keyword_heuristic(question, out)
        out = _prior_df_apply_post_heuristics(out, question, allowed_ids)
        _maybe_nudge_web_enrichment_keyword_heuristic(question, out)
        # Explicit agent-name override runs AFTER prior_df / file-reconcile / web heuristics
        # so those special modes are preserved (override is a no-op for them), but it runs
        # BEFORE clarification normalization so a single named agent skips disambiguation.
        out = _apply_explicit_agent_name_override(out, question, agent_profiles, allowed_ids)
        out = _ensure_file_reconcile_routing_shape(out)
        out = _normalize_intent_routing_for_clarification(out, allowed_ids)
        return _ensure_clarification_agent_ids(out, allowed_ids)
    except Exception as e:
        _talkback_agent_log(agent, f"suggest_intent_and_agents failed: {e}", "IntentRouting")
        return {
            "error": str(e),
            "intent": "ambiguous",
            "suggested_agent_ids": [],
            "needs_clarification": True,
            "clarification_message": "Routing failed; please rephrase your question.",
            "prior_df_followup": "none",
            "misrouted_web": False,
        }


def talkback_classify_context_relevance(
    service: Any,
    previous_turns: List[Dict[str, Any]],
    current_question: str,
    **kwargs: Any,
) -> Dict[str, Any]:
    return talkback_is_related_to_previous_context(
        service._ensure_agent(),
        previous_turns,
        current_question,
        **kwargs,
    )


def talkback_generate_contextual_question_smart_service(
    service: Any,
    previous_questions: list,
    current_question: str,
    **kwargs: Any,
) -> str:
    return talkback_generate_contextual_question_smart(
        service._ensure_agent(),
        previous_questions,
        current_question,
        **kwargs,
    )


def talkback_suggest_intent_and_agents(
    service: Any,
    question: str,
    agent_ids: Optional[List[str]] = None,
    default_agent_id: Optional[str] = None,
    **kwargs: Any,
) -> Dict[str, Any]:
    from app.services.askme_service import UPDATED_MODULES_AVAILABLE

    resolved: Optional[List[str]] = None
    if agent_ids and len(agent_ids) > 0:
        resolved = [str(a).strip() for a in agent_ids if str(a).strip()]
    elif getattr(service, "_agent_ids", None):
        resolved = [str(a).strip() for a in service._agent_ids if str(a).strip()]
    elif default_agent_id and str(default_agent_id).strip():
        resolved = [str(default_agent_id).strip()]

    if not resolved:
        return {
            "intent": "other",
            "suggested_agent_ids": [],
            "needs_clarification": True,
            "clarification_message": "No agent_ids provided; pass agent_ids or default_agent_id.",
            "prior_df_followup": "none",
            "misrouted_web": False,
        }

    if not UPDATED_MODULES_AVAILABLE:
        return {
            "intent": "other",
            "suggested_agent_ids": [],
            "needs_clarification": True,
            "clarification_message": "Askme modules unavailable.",
            "prior_df_followup": "none",
            "misrouted_web": False,
        }

    agent_profiles = talkback_build_agent_profiles_for_routing(resolved)
    a = service._ensure_agent()
    return talkback_suggest_intent_and_agents_impl(a, question, agent_profiles, **kwargs)


# Aliases for talkback_orchestrator (underscore-prefixed names used in pipeline logs)
_talkback_build_single_agent_routing_text = talkback_build_single_agent_routing_text
_talkback_build_agent_profiles_for_routing = talkback_build_agent_profiles_for_routing
_talkback_classify_context_relevance = talkback_classify_context_relevance
_talkback_generate_contextual_question_smart = talkback_generate_contextual_question_smart_service
_talkback_merge_captured_question_time_fragment = talkback_merge_captured_question_time_fragment
_talkback_suggest_intent_and_agents = talkback_suggest_intent_and_agents
_talkback_is_broad_or_underconstrained_query = talkback_is_broad_or_underconstrained_query
_talkback_followup_is_time_expression_only = talkback_followup_is_time_expression_only
