"""LLM-first query planning with deterministic guards for risky queries."""

from __future__ import annotations

import json
import re
import unicodedata

from . import http, providers, query, schema

# Hebrew Unicode block: U+0590–U+05FF
_HEBREW_RE = re.compile(r'[\u0590-\u05FF]')


def detect_language(text: str) -> str | None:
    """Return 'he' if the text contains Hebrew characters, else None."""
    return 'he' if _HEBREW_RE.search(text) else None

ALLOWED_INTENTS = {
    "factual",
    "product",
    "concept",
    "opinion",
    "how_to",
    "comparison",
    "breaking_news",
    "prediction",
}
ALLOWED_CLUSTER_MODES = {"none", "story", "workflow", "market", "debate"}
QUICK_SOURCE_PRIORITY = {
    "factual": ["hackernews", "reddit", "x", "xquik", "youtube"],
    "product": ["jobs", "youtube", "reddit", "x", "xquik", "tiktok"],
    "concept": ["hackernews", "reddit", "x", "xquik", "youtube"],
    "opinion": ["reddit", "x", "xquik", "youtube", "hackernews"],
    "how_to": ["youtube", "reddit", "x", "xquik", "hackernews"],
    "comparison": ["reddit", "x", "xquik", "hackernews", "youtube"],
    "breaking_news": ["x", "xquik", "reddit", "hackernews", "youtube", "polymarket"],
    "prediction": ["polymarket", "x", "xquik", "hackernews", "reddit", "youtube"],
}
SOURCE_PRIORITY = {
    "factual": ["hackernews", "reddit", "x", "youtube"],
    "product": ["jobs", "youtube", "reddit", "x", "tiktok", "hackernews"],
    "concept": ["hackernews", "reddit", "x", "youtube"],
    "opinion": ["reddit", "x", "stocktwits", "youtube", "hackernews"],
    "how_to": ["youtube", "reddit", "x", "hackernews"],
    "comparison": ["reddit", "x", "hackernews", "youtube"],
    "breaking_news": ["x", "stocktwits", "reddit", "hackernews", "youtube", "polymarket"],
    "prediction": ["polymarket", "stocktwits", "x", "hackernews", "reddit", "youtube"],
}
SOURCE_LIMITS = {
    "quick": {
        "factual": 2,
        "product": 2,
        "concept": 2,
        "opinion": 2,
        "how_to": 2,
        "comparison": 2,
        "breaking_news": 2,
        "prediction": 2,
    },
    # "default" intentionally absent: all available sources are searched
    # at default depth. Fusion and reranking handle quality. quick mode
    # uses tight budgets above for latency.
}
INTENT_SOURCE_EXCLUSIONS: dict[str, set[str]] = {
    "concept": {"polymarket"},
    "how_to": {"polymarket"},
}
SOURCE_CAPABILITIES = {
    "reddit": {"discussion", "social"},
    "x": {"discussion", "social"},
    "xquik": {"discussion", "social"},
    "youtube": {"video", "video_longform", "discussion"},
    "tiktok": {"video", "video_shortform", "social"},
    "instagram": {"video", "video_shortform", "social"},
    "hackernews": {"discussion", "link"},
    "bluesky": {"discussion", "social"},
    "truthsocial": {"discussion", "social"},
    "polymarket": {"market"},
    "stocktwits": {"social", "market", "finance_social"},
    "digg": {"discussion", "social", "link"},
    "arxiv": {"reference", "analysis", "link"},
    "techmeme": {"discussion", "link", "reference"},
    "trustpilot": {"reference", "company_signal", "social"},
    "xiaohongshu": {"video", "video_shortform", "social"},
    "github": {"discussion", "link"},
    "grounding": {"web", "reference", "link"},
    "perplexity": {"web", "reference", "analysis"},
    "jobs": {"jobs", "company_signal", "link"},
}
DEFAULT_INTENT_CAPABILITIES = {
    "comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
    "how_to": {"discussion", "video", "web", "reference", "link"},
}

def plan_query(
    *,
    topic: str,
    available_sources: list[str],
    requested_sources: list[str] | None,
    depth: str,
    provider: providers.ReasoningClient | None,
    model: str | None,
    context: str = "",
    internal_subrun: bool = False,
) -> schema.QueryPlan:
    """Create a query plan. Comparison queries with extractable entities use a
    deterministic plan; other intents prefer the configured reasoning provider.

    internal_subrun: when True, suppress the LAW 7 "No --plan passed" stderr
    warning. LAW 7 targets the hosting-reasoning-model path; competitor
    fan-out sub-runs are engine-internal and the warning is a false positive
    there. Default False preserves the warning on every user-facing invocation.
    """
    if _should_force_deterministic_plan(topic):
        return _fallback_plan(
            topic,
            available_sources,
            requested_sources,
            depth,
            note="deterministic-comparison-plan",
        )
    prompt = _build_prompt(topic, available_sources, requested_sources, depth)
    if context:
        prompt += f"\n\nCurrent context (from web search): {context}"
    if provider and model:
        try:
            raw = provider.generate_json(model, prompt)
            plan = _sanitize_plan(raw, topic, available_sources, requested_sources, depth)
            if plan.subqueries:
                return plan
        except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
            import sys
            print(f"[Planner] LLM planning failed, using deterministic fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
            return _fallback_plan(
                topic, available_sources, requested_sources, depth,
                note=f"fallback-plan (LLM error: {type(exc).__name__})",
            )
    # No --plan was passed and no engine-internal provider is configured.
    # The deterministic fallback path produces weaker plans than either the
    # --plan path or the engine-internal LLM planner path. Surface this so
    # the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.)
    # realizes it is supposed to generate the plan itself and pass --plan.
    # The word "provider" in engine messages refers to the engine's INTERNAL
    # planner credentials - NOT a prerequisite the caller needs. If you are
    # the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19
    # Hermes Agent Use Cases failure mode.
    if not internal_subrun:
        import sys
        print(
            "[Planner] No --plan passed. If you are the reasoning model hosting "
            "this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), "
            "YOU ARE the planner: generate a JSON query plan yourself and pass it "
            "via --plan. You do not need an API key or credentials; you ARE the "
            "LLM. The deterministic fallback below is the headless/cron path only. "
            "See LAW 7 in SKILL.md and Step 0.75 for the plan schema.",
            file=sys.stderr,
        )
    return _fallback_plan(topic, available_sources, requested_sources, depth)


def _build_prompt(
    topic: str,
    available_sources: list[str],
    requested_sources: list[str] | None,
    depth: str,
) -> str:
    requested = ", ".join(requested_sources or ["auto"])
    available = ", ".join(available_sources)
    return f"""
You are the query planner for a live last-30-days research pipeline.

Topic: {topic}
Depth: {depth}
Available sources: {available}
Requested sources: {requested}

Return JSON only with this shape:
{{
  "intent": "factual|product|concept|opinion|how_to|comparison|breaking_news|prediction",
  "freshness_mode": "strict_recent|balanced_recent|evergreen_ok",
  "cluster_mode": "none|story|workflow|market|debate",
  "source_weights": {{"source_name": 0.0}},
  "subqueries": [
    {{
      "label": "short label",
      "search_query": "keyword style query for search APIs",
      "ranking_query": "natural language rewrite for reranking",
      "sources": ["reddit", "x", "grounding"],
      "weight": 1.0
    }}
  ],
  "notes": ["optional short notes"]
}}

Rules:
- emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2)
- every subquery must include both search_query and ranking_query
- sources must be drawn from Available sources only
- use cluster_mode=none for factual or many how-to queries
- use strict_recent for breaking news and most predictions
- use debate for comparison/opinion, market for prediction, workflow for how_to, story for breaking_news
- search_query should be concise and keyword-heavy
- ranking_query should read like a natural-language question
- preserve exact proper nouns and entity strings from the topic
- NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers
- NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments'
- INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase.
- DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches.
- search_query should match how content is TITLED on platforms
- GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026'
""".strip()


def _sanitize_plan(
    raw: dict,
    topic: str,
    available_sources: list[str],
    requested_sources: list[str] | None,
    depth: str,
) -> schema.QueryPlan:
    intent_hint = str(raw.get("intent") or _infer_intent(topic)).strip()
    if intent_hint not in ALLOWED_INTENTS:
        intent_hint = _infer_intent(topic)
    requested = set(requested_sources or [])
    available = set(available_sources)
    eligible_sources = [
        source for source in available_sources
        if (not requested or source in requested)
    ]
    source_weights = {
        source: float(weight)
        for source, weight in (raw.get("source_weights") or {}).items()
        if source in available
    }
    if requested:
        source_weights = {source: weight for source, weight in source_weights.items() if source in requested}
    if not source_weights:
        source_weights = _default_source_weights(_infer_intent(topic), eligible_sources)
    # Ensure all eligible sources are available for subqueries. The LLM may
    # assign high weights to its preferred sources, but omitted sources still
    # participate with base weight so retrieval can overfetch and let fusion
    # decide quality.
    for source in eligible_sources:
        source_weights.setdefault(source, 1.0)
    if intent_hint in DEFAULT_INTENT_CAPABILITIES and depth != "quick":
        for source in _default_sources_for_intent(intent_hint, eligible_sources):
            source_weights.setdefault(source, 1.0)
    source_weights = _normalize_weights(source_weights)

    subqueries: list[schema.SubQuery] = []
    for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1):
        if not isinstance(subquery, dict):
            continue
        sources = [source for source in subquery.get("sources") or [] if source in source_weights]
        if requested:
            sources = [source for source in sources if source in requested]
        if not sources:
            sources = list(source_weights)
        search_query = str(subquery.get("search_query") or "").strip()
        ranking_query = str(subquery.get("ranking_query") or "").strip()
        if not search_query or not ranking_query:
            continue
        subqueries.append(
            schema.SubQuery(
                label=str(subquery.get("label") or f"q{index}").strip() or f"q{index}",
                search_query=search_query,
                ranking_query=ranking_query,
                sources=sources,
                weight=max(0.05, float(subquery.get("weight") or 1.0)),
            )
        )
    if depth == "quick" and subqueries:
        subqueries = subqueries[:1]
    if not subqueries:
        return _fallback_plan(topic, available_sources, requested_sources, depth)

    intent = intent_hint
    freshness_mode = str(raw.get("freshness_mode") or _default_freshness(intent)).strip()
    if intent == "how_to":
        freshness_mode = "evergreen_ok"
    cluster_mode = str(raw.get("cluster_mode") or _default_cluster_mode(intent)).strip()
    if cluster_mode not in ALLOWED_CLUSTER_MODES:
        cluster_mode = _default_cluster_mode(intent)

    return schema.QueryPlan(
        intent=intent,
        freshness_mode=freshness_mode,
        cluster_mode=cluster_mode,
        raw_topic=topic,
        subqueries=_normalize_subquery_weights(
            _trim_subqueries_for_depth(
                subqueries,
                intent,
                depth,
                eligible_sources,
                requested_sources=requested_sources,
            )
        ),
        source_weights=source_weights,
        notes=[str(note).strip() for note in raw.get("notes") or [] if str(note).strip()],
    )


def _normalize_subquery_weights(subqueries: list[schema.SubQuery]) -> list[schema.SubQuery]:
    total = sum(subquery.weight for subquery in subqueries) or 1.0
    return [
        schema.SubQuery(
            label=subquery.label,
            search_query=subquery.search_query,
            ranking_query=subquery.ranking_query,
            sources=subquery.sources,
            weight=subquery.weight / total,
        )
        for subquery in subqueries
    ]


def _normalize_weights(weights: dict[str, float]) -> dict[str, float]:
    total = sum(max(weight, 0.0) for weight in weights.values()) or 1.0
    return {
        source: max(weight, 0.0) / total
        for source, weight in weights.items()
    }


def _trim_subqueries_for_depth(
    subqueries: list[schema.SubQuery],
    intent: str,
    depth: str,
    available_sources: list[str],
    requested_sources: list[str] | None = None,
) -> list[schema.SubQuery]:
    # At non-quick depth, expand sources: use capability routing for intents
    # that define it, or all available sources otherwise. The LLM planner may
    # assign narrow source lists; we override to let fusion decide quality.
    if depth != "quick":
        expanded_sources = _default_sources_for_intent(intent, available_sources)
        return [
            schema.SubQuery(
                label=subquery.label,
                search_query=subquery.search_query,
                ranking_query=subquery.ranking_query,
                sources=expanded_sources,
                weight=subquery.weight,
            )
            for subquery in subqueries
        ]
    limits = SOURCE_LIMITS.get(depth)
    if not limits:
        return subqueries
    priority_table = QUICK_SOURCE_PRIORITY if depth == "quick" else SOURCE_PRIORITY
    priority = priority_table.get(intent, priority_table["breaking_news"])
    limit = limits.get(intent, 3)
    ranked_sources = [source for source in priority if source in available_sources]
    if not ranked_sources:
        ranked_sources = list(available_sources)
    trimmed = []
    for subquery in subqueries:
        if depth in {"quick", "default"}:
            preferred_sources = ranked_sources[:limit]
            if requested_sources:
                requested = [
                    source
                    for source in requested_sources
                    if source in available_sources and source in subquery.sources
                ]
                for source in requested:
                    if source not in preferred_sources:
                        preferred_sources.append(source)
        else:
            preferred_sources = [source for source in ranked_sources if source in subquery.sources][:limit]
            if len(preferred_sources) < limit:
                for source in ranked_sources:
                    if source in preferred_sources:
                        continue
                    preferred_sources.append(source)
                    if len(preferred_sources) >= limit:
                        break
        trimmed.append(
            schema.SubQuery(
                label=subquery.label,
                search_query=subquery.search_query,
                ranking_query=subquery.ranking_query,
                sources=preferred_sources,
                weight=subquery.weight,
            )
        )
    return trimmed


def _fallback_plan(
    topic: str,
    available_sources: list[str],
    requested_sources: list[str] | None,
    depth: str,
    note: str = "fallback-plan",
) -> schema.QueryPlan:
    intent = _infer_intent(topic)
    # Hebrew-language topics: elevate web search (grounding) to the front of
    # the source list since Reddit/HN/GitHub are English-dominant platforms.
    # Grounding covers Ynet, Walla, Mako, N12 etc. if a web search key is set.
    if detect_language(topic) == 'he' and 'grounding' in available_sources:
        ordered = ['grounding'] + [s for s in available_sources if s != 'grounding']
        available_sources = ordered
        if requested_sources:
            requested_sources = ['grounding'] + [s for s in requested_sources if s != 'grounding']
    allowed_sources = requested_sources or available_sources
    source_weights = _default_source_weights(intent, allowed_sources)
    core = query.extract_core_subject(topic, max_words=6, strip_suffixes=True)
    base_search = _keyword_query(topic, core)
    base_ranking = _ranking_query(topic, core)

    subqueries = [schema.SubQuery(
        label="primary",
        search_query=base_search,
        ranking_query=base_ranking,
        sources=list(source_weights),
        weight=1.0,
    )]

    if depth != "quick" and intent == "comparison":
        entities = _comparison_entities(topic)
        if entities:
            for index, entity in enumerate(entities, start=1):
                subqueries.append(
                    schema.SubQuery(
                        label=f"entity-{index}",
                        search_query=entity,
                        ranking_query=f"What recent evidence from the last 30 days is most relevant to {entity} in the comparison '{topic}'?",
                        sources=list(source_weights),
                        weight=0.65,
                    )
                )
    elif depth != "quick" and intent == "prediction":
        subqueries.append(
            schema.SubQuery(
                label="odds",
                search_query=f"{base_search} odds forecast",
                ranking_query=f"What are the current odds, forecasts, or market signals about {topic}?",
                sources=[source for source in source_weights if source in {"polymarket", "grounding", "x", "reddit"}] or list(source_weights),
                weight=0.7,
            )
        )
    elif depth != "quick" and intent == "breaking_news":
        subqueries.append(
            schema.SubQuery(
                label="reaction",
                search_query=f"{base_search} reaction update",
                ranking_query=f"What new reactions or follow-up reporting from the last 30 days matter for {topic}?",
                sources=[source for source in source_weights if source in {"x", "reddit", "grounding", "hackernews"}] or list(source_weights),
                weight=0.7,
            )
        )

    # Intent-modifier fanout: when topic contains a phrase like "use cases",
    # "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS),
    # paraphrase the intent across 3 extra subqueries rather than echoing
    # the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure.
    # Excluded for comparison/prediction since those already have dedicated
    # fanout (entity-per-subquery / odds).
    if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic):
        subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights))

    return schema.QueryPlan(
        intent=intent,
        freshness_mode=_default_freshness(intent),
        cluster_mode=_default_cluster_mode(intent),
        raw_topic=topic,
        subqueries=_normalize_subquery_weights(
            _trim_subqueries_for_depth(
                subqueries[:_max_subqueries(intent, topic)],
                intent,
                depth,
                list(source_weights),
                requested_sources=requested_sources,
            )
        ),
        source_weights=_normalize_weights(source_weights),
        notes=[note],
    )


def _infer_intent(topic: str) -> str:
    text = topic.lower().strip()
    if re.search(r"\b(vs|versus|compare|compared to|difference between)\b", text):
        return "comparison"
    # Slash-separated proper nouns: "React/Vue/Svelte" (not URLs, not acronyms like CI/CD or I/O)
    if not re.search(r"https?://", topic) and re.search(r"\b[A-Z][a-z]{2,}(?:/[A-Z][a-z]{2,})+\b", topic):
        return "comparison"
    if re.search(r"\b(odds|predict|prediction|forecast|chance|probability|will .* win)\b", text):
        return "prediction"
    if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install)\b", text):
        return "how_to"
    if re.search(r"\b(what is|what are|who is|who acquired|when did|parameter count|release date)\b", text):
        return "factual"
    if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text):
        return "opinion"
    if re.search(r"\b(latest|news|announced|just shipped|launched|released|update)\b", text):
        return "breaking_news"
    if re.search(r"\b(pricing|feature|features|best .* for|top .* for)\b", text):
        return "product"
    if re.search(r"\b(explain|concept|protocol|architecture|what does)\b", text):
        return "concept"
    if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text):
        return "breaking_news"
    # Recency signals take priority when nothing more specific matched.
    if re.search(r"\b(trending|this week|right now|today|this month)\b", text):
        return "breaking_news"
    # Default changed from "breaking_news" to "concept" on 2026-04-19 after
    # the Hermes Agent Use Cases failure: unclassified topics were getting
    # strict_recent freshness, which over-weighted the last 7 days and
    # under-weighted older relevant material. "concept" defaults to
    # evergreen_ok freshness, a safer posture for unknown topics.
    return "concept"


def _default_freshness(intent: str) -> str:
    if intent in {"breaking_news", "prediction"}:
        return "strict_recent"
    if intent in {"concept", "how_to"}:
        return "evergreen_ok"
    return "balanced_recent"


def _default_cluster_mode(intent: str) -> str:
    return {
        "breaking_news": "story",
        "comparison": "debate",
        "opinion": "debate",
        "prediction": "market",
        "how_to": "workflow",
        "factual": "none",
        "product": "none",
        "concept": "none",
    }.get(intent, "none")


def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]:
    base = {source: 1.0 for source in sources}
    if intent == "prediction":
        for source, bonus in {"polymarket": 2.5, "x": 1.3}.items():
            if source in base:
                base[source] += bonus
    elif intent == "breaking_news":
        for source, bonus in {"x": 1.5, "reddit": 1.3, "hackernews": 0.8}.items():
            if source in base:
                base[source] += bonus
    elif intent == "how_to":
        for source, bonus in {"youtube": 2.0, "hackernews": 0.8}.items():
            if source in base:
                base[source] += bonus
    elif intent == "factual":
        for source, bonus in {"reddit": 0.8, "x": 0.5}.items():
            if source in base:
                base[source] += bonus
    elif intent == "product":
        for source, bonus in {"jobs": 0.8, "youtube": 0.5}.items():
            if source in base:
                base[source] += bonus
    return base


def _keyword_query(topic: str, core: str) -> str:
    """Build a search_query string for the deterministic fallback.

    Quote ONLY title-cased multi-word proper nouns ("Hermes Agent",
    "Claude Code", "Nous Research") so platform search engines preserve the
    name as a phrase. Hyphenated compounds and lowercase terms are left as
    bare keywords, which broadens retrieval instead of narrowing it.

    Prior behavior quoted the entire compound including the user's typed
    topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual`
    that returned near-zero matches on X and Reddit because nobody posts
    that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure.
    """
    compounds = query.extract_compound_terms(topic)
    # Only quote title-cased proper nouns (multi-word names). Hyphenated
    # compounds go unquoted so platform tokenizers can split and match.
    title_cased = [
        term for term in compounds
        if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
    ]
    quoted = " ".join(f'"{term}"' for term in title_cased[:2])
    keywords = [quoted.strip(), core.strip() or topic.strip()]
    return " ".join(part for part in keywords if part).strip()


def _ranking_query(topic: str, core: str) -> str:
    if topic.strip().endswith("?"):
        return topic.strip()
    if core and core.lower() != topic.lower():
        return f"What recent evidence from the last 30 days is most relevant to {topic}, especially about {core}?"
    return f"What recent evidence from the last 30 days is most relevant to {topic}?"


_TRAILING_CONTEXT = re.compile(
    r"\s+\b(?:for|in|on|at|to|with|about|from|by|during|since|after|before|using|via)\b.*$",
    re.I,
)


def _comparison_entities(topic: str) -> list[str]:
    # "difference between X and Y" -> "X vs Y" (replace "and" only in this context)
    normalized = re.sub(
        r"\bdifference between\s+(.+?)\s+and\s+",
        r"\1 vs ",
        topic,
        flags=re.I,
    )
    normalized = re.sub(r"\b(compared to)\b", " vs ", normalized, flags=re.I)
    parts = [
        part.strip(" \t\r\n?.,:;!()[]{}\"'")
        for part in re.split(r"\bvs\.?\b|\bversus\b|/", normalized, flags=re.I)
        if part.strip(" \t\r\n?.,:;!()[]{}\"'")
    ]
    # Strip trailing context from parts ("Svelte for frontend in 2026" -> "Svelte")
    if len(parts) >= 2:
        parts = [_TRAILING_CONTEXT.sub("", part).strip() or part for part in parts]
        deduped = []
        for part in parts:
            if part and part not in deduped:
                deduped.append(part)
        return deduped[:_max_subqueries("comparison")]
    return []


def _should_force_deterministic_plan(topic: str) -> bool:
    return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2


_INTENT_MODIFIER_PATTERNS = (
    "use cases", "use case", "workflows", "workflow",
    "examples", "example", "tutorial", "tutorials",
    "review", "reviews", "comparison", "applications",
    "in practice", "production use", "production",
    "how i use",
)


def _has_intent_modifier(topic: str) -> bool:
    """Return True if the topic contains an intent modifier phrase.

    See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent
    use cases" search returns near-zero matches because nobody posts that
    exact phrase. Intent modifiers should be stripped from search_query
    and paraphrased across multiple subqueries.
    """
    text = topic.lower()
    return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS)


def _intent_modifier_subqueries(
    topic: str,
    core: str,
    base_search: str,
    source_weights: dict[str, float],
) -> list[schema.SubQuery]:
    """Produce paraphrased subqueries for intent-modifier topics.

    The deterministic fallback used to echo the user's literal phrase
    (e.g., "hermes agent use cases") into every search_query. This helper
    fans out 3 extra subqueries that each express the intent differently
    so retrieval pulls a broader corpus for reranking.
    """
    entity = core or topic.strip()
    sources = list(source_weights)
    return [
        schema.SubQuery(
            label="workflows",
            search_query=f"{entity} workflow pipeline",
            ranking_query=f"What real-world workflows or pipelines are people running with {entity}?",
            sources=sources,
            weight=0.6,
        ),
        schema.SubQuery(
            label="production",
            search_query=f"{entity} production real-world",
            ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?",
            sources=sources,
            weight=0.55,
        ),
        schema.SubQuery(
            label="experience",
            search_query=f"{entity} experience review",
            ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?",
            sources=sources,
            weight=0.5,
        ),
    ]


def _max_subqueries(intent: str, topic: str | None = None) -> int:
    # how_to/opinion/product/breaking_news/prediction benefit from 4-5
    # paraphrased subqueries when the topic carries an intent modifier
    # (use cases, workflows, examples, review, etc.). See 2026-04-19
    # Hermes Agent Use Cases failure: prior cap of 3 produced near-literal
    # echoes of the topic instead of a paraphrase fanout.
    if intent == "comparison":
        return 4
    # Intent-modifier topics get headroom for paraphrase fanout even when
    # the intent itself is factual/concept. Without this, a "Hermes Agent
    # use cases" query (classified "concept" after the 2026-04-19 default
    # change) would be capped at 2 and drop the fanout.
    if topic and _has_intent_modifier(topic):
        return 5
    if intent in {"factual", "concept"}:
        return 2
    return 5


def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
    if intent == "how_to":
        sources = _how_to_sources(available_sources)
    else:
        target_capabilities = DEFAULT_INTENT_CAPABILITIES.get(intent)
        if not target_capabilities:
            sources = list(available_sources)
        else:
            matched = [
                source
                for source in available_sources
                if SOURCE_CAPABILITIES.get(source, set()) & target_capabilities
            ]
            sources = matched or list(available_sources)
    excluded = INTENT_SOURCE_EXCLUSIONS.get(intent, set())
    if excluded:
        filtered = [s for s in sources if s not in excluded]
        return filtered or sources
    return sources


def _how_to_sources(available_sources: list[str]) -> list[str]:
    """Pick one source per role: web/reference, video (prefer longform), discussion."""
    selected: set[str] = set()
    has_video = False
    # Order matters: web first, then longform video, generic video, discussion.
    role_capabilities = [
        {"web", "reference"},
        {"video_longform"},
        {"video"},
        {"discussion"},
    ]
    for role in role_capabilities:
        is_video_role = role & {"video", "video_longform"}
        if is_video_role and has_video:
            continue
        for source in available_sources:
            if source in selected:
                continue
            if SOURCE_CAPABILITIES.get(source, set()) & role:
                selected.add(source)
                if is_video_role:
                    has_video = True
                break
    # After core role-based selection, include remaining sources with any
    # how_to-relevant capability (video, discussion, web, reference, link).
    how_to_caps = DEFAULT_INTENT_CAPABILITIES.get("how_to", set())
    for source in available_sources:
        if source not in selected and SOURCE_CAPABILITIES.get(source, set()) & how_to_caps:
            selected.add(source)
    if not selected:
        return list(available_sources)
    return [source for source in available_sources if source in selected]
