# sales_brain.py
from __future__ import annotations
import json
from typing import Dict, Any, List, Optional, Tuple

from openai import AzureOpenAI
from config import (
    AZURE_OPENAI_ENDPOINT,
    AZURE_OPENAI_KEY,
    AZURE_OPENAI_API_VERSION,
    AZURE_OPENAI_DEPLOYMENT,
    AZURE_OPENAI_TEMPERATURE,
    AZURE_OPENAI_MAX_TOKENS,
    HIGH_BUDGET_THRESHOLD,
    DEV_LAPTOP_HIGH_BUDGET_THRESHOLD,
    validate_config,
)
from context_utils import (
    detect_financial_sentiment,
    detect_sensitive_domains,
    infer_os_preference,
    is_dev_laptop_context,
    max_budget_amount,
)
from playbook import TenantPlaybook
from prompt_cache import PromptCache
from prompt_compiler import CompileKey, compile_system_prompt
from tactics import detect_signals, TACTIC_MODULES
from status_engine import (
    StatusDirective,
    build_status_directive,
    classify_deal,
    choose_seller_posture,
    choose_decision_pressure,
    next_tension_stage,
)
from schemas import BrainOutput, Extracted, SalesState

_PROMPT_CACHE = PromptCache()

FRICTION_QUESTIONS = [
    "Is this something you usually act on, or do you prefer to wait until it is obvious?",
    "Are you comfortable moving before consensus, or do you need full certainty?",
    "Do you prefer certainty, or are you willing to move early for upside?",
]

def default_playbook() -> TenantPlaybook:
    return TenantPlaybook(
        tenant_id="default",
        vertical="other",
        channel_defaults={"primary": "whatsapp"},
        tone="neutral",
        methodology="consultative",
        allowed_claims=[],
        forbidden_claims=[
            "guaranteed returns",
            "investment guarantee",
            "guaranteed availability",
            "guaranteed integrations",
        ],
        pricing_policy="no pricing until qualified",
        close_policy="soft close",
        version=1,
    )

# Required fields by offer category and state
OFFER_REQUIRED_FIELDS: Dict[str, Dict[SalesState, List[str]]] = {
    "service": {
        "GREETING": ["use_case"],
        "DISCOVERY": ["use_case", "buyer_role", "company"],
        "QUALIFICATION": [],
        "VALUE": [],
        "OBJECTIONS": [],
        "NEXT_STEP": ["email"],
        "HANDOFF": [],
    },
    "retail": {
        "GREETING": ["product"],
        "DISCOVERY": ["product"],
        "QUALIFICATION": ["delivery_location"],
        "VALUE": [],
        "OBJECTIONS": [],
        "NEXT_STEP": [],
        "HANDOFF": [],
    },
    "art_luxury": {
        "GREETING": ["piece_id"],
        "DISCOVERY": ["piece_id"],
        "QUALIFICATION": ["delivery_location", "authenticity_facts"],
        "VALUE": [],
        "OBJECTIONS": [],
        "NEXT_STEP": [],
        "HANDOFF": [],
    },
    "software": {
        "GREETING": ["use_case"],
        "DISCOVERY": ["use_case"],
        "QUALIFICATION": ["os_preference", "budget_range"],
        "VALUE": [],
        "OBJECTIONS": [],
        "NEXT_STEP": [],
        "HANDOFF": [],
    },
    "other": {
        "GREETING": ["use_case"],
        "DISCOVERY": ["use_case"],
        "QUALIFICATION": [],
        "VALUE": [],
        "OBJECTIONS": [],
        "NEXT_STEP": [],
        "HANDOFF": [],
    },
}

def normalize_offer_category(raw: Any) -> str:
    if raw in ("art", "art_luxury"):
        return "art_luxury"
    if raw in ("service", "retail", "software"):
        return str(raw)
    return "other"


def split_memory(memory: Dict[str, Any] | None) -> Tuple[Dict[str, Any], Dict[str, Any]]:
    if not memory:
        return {}, {}
    meta = dict(memory.get("_meta") or {})
    user_memory = {k: v for k, v in memory.items() if k != "_meta"}
    return user_memory, meta


def normalize_financial_sentiment(value: Any) -> Optional[str]:
    if not value:
        return None
    val = str(value).strip().lower()
    if val in ("broke", "tight", "negative"):
        return "broke" if val == "broke" else "tight"
    if val in ("neutral", "ok", "okay", "normal"):
        return "neutral"
    if val in ("comfortable", "flexible", "positive"):
        return "comfortable"
    return None


def build_os_question(memory: Dict[str, Any], user_message: str, budget_amount: Optional[int]) -> str:
    context_text = " ".join(
        [
            str(memory.get("use_case") or ""),
            str(memory.get("product") or ""),
            user_message,
        ]
    )
    if budget_amount and is_dev_laptop_context(context_text) and budget_amount >= DEV_LAPTOP_HIGH_BUDGET_THRESHOLD:
        return (
            "With that budget, we're looking at top-tier MacBooks or premium Windows workstations. "
            "Do you prefer the Apple ecosystem or Windows?"
        )
    return "Do you prefer macOS, Windows, or Linux?"


def build_supervisor_note(
    user_message: str,
    memory: Dict[str, Any],
    budget_amount: Optional[int],
) -> Tuple[Optional[str], List[str]]:
    prev_sentiment = normalize_financial_sentiment(memory.get("financial_sentiment"))
    current_sentiment = detect_financial_sentiment(user_message)
    sentiment = current_sentiment or prev_sentiment
    if sentiment not in ("broke", "tight"):
        return None, []
    if not budget_amount:
        return None, []

    context_text = " ".join([str(memory.get("use_case") or ""), str(memory.get("product") or ""), user_message])
    threshold = DEV_LAPTOP_HIGH_BUDGET_THRESHOLD if is_dev_laptop_context(context_text) else HIGH_BUDGET_THRESHOLD
    if budget_amount < threshold:
        return None, []

    alt = max(100, int(round(budget_amount / 10)))
    note = (
        "Supervisor check: possible budget/sentiment mismatch.\n"
        f"- Sentiment: {sentiment}\n"
        f"- Budget mentioned: ${budget_amount:,}\n"
        "Guideline: Acknowledge money stress and confirm the budget before recommending. "
        "Do not celebrate the budget.\n"
        f'Suggested line: "You mentioned being tight on money, but ${budget_amount:,} is a high budget. '
        f'Did you mean around ${alt:,}, or are you comfortable spending that much for the right tool?"'
    )
    return note, ["budget_sentiment_mismatch"]


def build_question_plan(
    state: SalesState,
    memory: Dict[str, Any],
    meta: Dict[str, Any],
    user_message: str,
    budget_amount: Optional[int],
    signals: List[str],
    status: StatusDirective,
) -> Tuple[List[str], Optional[str], List[str], Optional[str]]:
    turn_index = int(meta.get("turn_index") or 0)
    last_asked = meta.get("last_asked") or {}

    def asked_recently(field: str, window: int = 2) -> bool:
        last = last_asked.get(field)
        return last is not None and (turn_index - int(last)) < window

    recent_fields = [f for f, last in last_asked.items() if (turn_index - int(last)) < 2]

    if "close_intent" in signals:
        return [], None, recent_fields, None

    # Prioritize OS preference for software offers.
    question_focus: List[str] = []
    suggested_question: Optional[str] = None
    friction_question: Optional[str] = None

    offer = normalize_offer_category(memory.get("offer_category"))
    context_text = " ".join([str(memory.get("use_case") or ""), str(memory.get("product") or ""), user_message])
    if offer == "other" and is_dev_laptop_context(context_text):
        offer = "software"
    if offer == "software" and not memory.get("os_preference"):
        if not asked_recently("os_preference"):
            question_focus = ["os_preference"]
            suggested_question = build_os_question(memory, user_message, budget_amount)
            return question_focus, suggested_question, recent_fields, None

    pre_missing = compute_missing(memory, state)
    for field in pre_missing:
        if asked_recently(field):
            continue
        question_focus = [field]
        break

    if question_focus and question_focus[0] == "os_preference":
        suggested_question = build_os_question(memory, user_message, budget_amount)

    # Optional buyer self-qualification friction (only if we have context to qualify).
    has_context = bool(memory.get("use_case") or memory.get("product"))
    if (
        status.friction_mode != "off"
        and has_context
        and not asked_recently("friction", window=3)
        and "time_pressure" not in signals
        and "trust" not in signals
    ):
        idx = max(0, turn_index) % len(FRICTION_QUESTIONS)
        friction_question = FRICTION_QUESTIONS[idx]

    return question_focus, suggested_question, recent_fields, friction_question


def render_question_plan(
    question_focus: List[str],
    suggested_question: Optional[str],
    recent_fields: List[str],
    friction_question: Optional[str],
) -> str:
    lines = ["Question plan (must follow):"]
    if recent_fields:
        lines.append(f"- Do NOT repeat questions about: {', '.join(recent_fields)}.")
    if not question_focus and not friction_question:
        lines.append("- Ask 0 questions this turn unless strictly required.")
        return "\n".join(lines)

    if question_focus:
        lines.append(f"- Question focus: {', '.join(question_focus)}.")
        lines.append("- If you ask a question, it must ONLY cover the focus fields unless you choose the friction question.")
        if suggested_question:
            lines.append(f"- Suggested phrasing: {suggested_question}")

    if friction_question:
        lines.append(f"- Optional friction question (use verbatim if used): {friction_question}")
        lines.append("- If you use the friction question, do NOT ask any other questions.")
    return "\n".join(lines)


def is_ml_offer(extracted: Dict[str, Any]) -> bool:
    if extracted.get("ml_intensity") in ("light", "medium", "heavy"):
        return True
    use_case = (extracted.get("use_case") or "").lower()
    return any(
        k in use_case
        for k in ["ml", "machine learning", "model", "training", "inference", "cuda", "pytorch", "tensorflow"]
    )

def compute_missing(extracted: Dict[str, Any], state: SalesState) -> List[str]:
    offer = normalize_offer_category(extracted.get("offer_category"))
    req = OFFER_REQUIRED_FIELDS.get(offer, OFFER_REQUIRED_FIELDS["other"]).get(state, [])
    if offer == "software" and is_ml_offer(extracted) and state in ("GREETING", "DISCOVERY", "QUALIFICATION"):
        req = [*req, "ml_intensity"]
    missing = []
    for f in req:
        val = extracted.get(f)
        if val is None or (isinstance(val, str) and not val.strip()):
            missing.append(f)
    return missing

def compute_lead_score(extracted: Dict[str, Any], state: SalesState) -> int:
    # Simple, deterministic scoring you control
    score = 10
    if extracted.get("buyer_role"): score += 5
    if extracted.get("company"): score += 5
    if extracted.get("use_case"): score += 15
    if extracted.get("ml_intensity"): score += 10
    if extracted.get("os_preference"): score += 10
    if extracted.get("budget_range"): score += 15
    if extracted.get("timeline"): score += 10
    if extracted.get("key_pain"): score += 10

    # Reward being further in the flow
    state_bonus = {
        "GREETING": 0,
        "DISCOVERY": 5,
        "QUALIFICATION": 10,
        "VALUE": 15,
        "OBJECTIONS": 15,
        "NEXT_STEP": 20,
        "HANDOFF": 20,
    }[state]
    score += state_bonus

    return max(0, min(100, score))

def next_state_logic(current: SalesState, extracted: Dict[str, Any]) -> SalesState:
    if extracted.get("committed"):
        return "NEXT_STEP"
    # if we already know this is heavy ML and needs CUDA, jump to VALUE quickly
    if extracted.get("ml_intensity") == "heavy":
        if extracted.get("use_case"):
            return "VALUE"

    if current == "GREETING":
        return "DISCOVERY"
    if current == "DISCOVERY":
        return "VALUE" if extracted.get("use_case") else "DISCOVERY"
    if current == "VALUE":
        return "NEXT_STEP"
    return current

def parse_json_only(text: str) -> Dict[str, Any]:
    # Robust parsing: handle pure JSON, or JSON wrapped in extra text.
    text = text.strip()
    if not text:
        raise ValueError("Empty model output.")
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    decoder = json.JSONDecoder()
    start = text.find("{")
    while start != -1:
        try:
            obj, _ = decoder.raw_decode(text[start:])
            return obj
        except json.JSONDecodeError:
            start = text.find("{", start + 1)
    raise ValueError("No JSON object found in model output.")


def _fallback_payload(raw: str) -> Dict[str, Any]:
    reply = raw.strip()
    if not reply:
        reply = "Sorry - I had trouble formatting that response. Please try again."
    return {
        "reply": reply,
        "extracted": {},
        "safety_flags": [],
        "brain": {},
    }


def _parse_model_output(raw: str) -> Dict[str, Any]:
    try:
        return parse_json_only(raw)
    except (ValueError, json.JSONDecodeError):
        return _fallback_payload(raw)


def render_status_directives(status: StatusDirective) -> str:
    lines = [
        "Status Engine (must follow):",
        f"- seller_posture: {status.seller_posture}",
        f"- deal_class: {status.deal_class}",
        f"- tension_stage: {status.tension_stage}",
        f"- disagreement_level: {status.disagreement_level}",
        f"- narrative_mode: {status.narrative_mode}",
        f"- friction_mode: {status.friction_mode}",
        f"- info_asymmetry: {status.info_asymmetry}",
        f"- decision_pressure: {status.decision_pressure}",
        "Rules:",
        "- Posture meaning: advisor=helpful, peer=equal, authority=confident, gatekeeper=selective.",
        "- Candidate framing: when posture is authority/gatekeeper, speak as a selector; the buyer earns fit.",
        "- Question restraint: when posture is authority/gatekeeper, prefer statements and ask at most ONE question total.",
        "- Decision pressure: low=exploratory/advisory, medium=firm guidance/narrowing, high=decisive recommendation.",
        "- Option narrowing: when decision_pressure is medium/high, present at most 2 paths and recommend one clearly.",
        "- Strategic disagreement: for common objections, issue ONE calm contradiction framed as insight, then replace it with a higher-order frame.",
        "- Narrative pressure: if narrative_mode is active, include 1-2 sentences with early vs late, insiders vs outsiders, missed opportunity, and identity shift; no promises.",
        "- Psychological pressure: use contrast, stakes, and identity; never aggressive or insulting.",
        "- Buyer self-requalification: use friction_mode only if allowed by the question plan.",
        "- Asymmetric information: signal pattern-level insight; do not invent facts or numbers.",
    ]
    return "\n".join(lines)

def build_messages(
    playbook: TenantPlaybook,
    state: SalesState,
    user_message: str,
    memory: Dict[str, Any],
    channel: str,
    rag_facts: str | None = None,
    supervisor_note: str | None = None,
    question_plan: str | None = None,
    status_directives: str | None = None,
    guardrail_note: str | None = None,
) -> List[Dict[str, str]]:
    key = CompileKey(
        tenant_id=playbook.tenant_id,
        vertical=playbook.vertical,
        channel=channel,
        tone=playbook.tone,
        methodology=playbook.methodology,
        version=playbook.version,
    )

    compiled = _PROMPT_CACHE.get(key)
    if not compiled:
        compiled = compile_system_prompt(playbook, channel=channel)
        _PROMPT_CACHE.set(key, compiled)

    signals = detect_signals(user_message, context=memory)
    if "echo_only" in signals:
        tactic_blocks = [TACTIC_MODULES["echo_only"]]
    else:
        tactic_blocks = [TACTIC_MODULES[s] for s in signals if s in TACTIC_MODULES]
    tactic_text = "\n".join(tactic_blocks).strip()

    memory_json = json.dumps(memory or {}, ensure_ascii=False)
    state_text = f"""
Current state: {state}
Known customer context (JSON): {memory_json}

Rules for this turn:
- In DISCOVERY: max 2 questions.
- In VALUE/NEXT_STEP/OBJECTIONS: max 1 question total, and only if it changes the recommendation.
- Always lead with a recommendation or positioning statement, then ask (if needed).
- If close_intent signal present: ask 0 questions and close confidently.
"""

    rag_text = ""
    if rag_facts:
        rag_text = f"Verified business facts (use these, do not invent):\n{rag_facts}"

    json_contract = """
Return ONLY valid JSON:
{
    "brain": {
        "buying_stage": null|"discovery"|"consideration"|"decision",
        "emotional_driver": null|"fear"|"greed"|"status"|"comfort"|"other",
        "methodology": null|"consultative"|"challenger"|"sandler",
        "tactic": null|string,
        "seller_posture": null|"advisor"|"peer"|"authority"|"gatekeeper",
        "tension_stage": null|"status"|"disruption"|"authority"|"possibility"|"commitment",
        "disagreement_level": null|"low"|"medium"|"high",
        "narrative_mode": null|"none"|"compressed_analogy"|"time_asymmetry",
        "friction_mode": null|"off"|"soft"|"firm",
        "info_asymmetry": null|"none"|"light"|"strong",
        "decision_pressure": null|"low"|"medium"|"high"
      },
  "reply": "string",
  "extracted": {
    "buyer_role": null|string,
    "company": null|string,
    "email": null|string,
    "industry": null|string,
    "use_case": null|string,
    "product": null|string,
    "delivery_location": null|string,
    "piece_id": null|string,
    "authenticity_facts": null|string,
    "offer_category": null|"art"|"software"|"service"|"retail",
    "ml_intensity": null|"none"|"light"|"medium"|"heavy",
    "os_preference": null|"macos"|"windows"|"linux"|"no_preference",
    "budget_range": null|string,
    "financial_sentiment": null|"broke"|"tight"|"neutral"|"comfortable",
    "timeline": null|string,
    "key_pain": null|string,
    "objections": [],
    "committed": null|true|false
  },
  "safety_flags": []
}
"""

    messages = [
        {"role": "system", "content": compiled},
        {"role": "system", "content": state_text.strip()},
    ]

    if rag_text:
        messages.append({"role": "system", "content": rag_text.strip()})
    if supervisor_note:
        messages.append({"role": "system", "content": supervisor_note.strip()})
    if question_plan:
        messages.append({"role": "system", "content": question_plan.strip()})
    if status_directives:
        messages.append({"role": "system", "content": status_directives.strip()})
    if guardrail_note:
        messages.append({"role": "system", "content": guardrail_note.strip()})
    if tactic_text:
        messages.append({"role": "system", "content": tactic_text})
    messages.append({"role": "system", "content": json_contract.strip()})
    messages.append({"role": "user", "content": user_message})

    return messages

class SalesBrain:
    def __init__(self, playbook: TenantPlaybook | None = None, channel: str | None = None):
        validate_config()
        self.client = AzureOpenAI(
            azure_endpoint=AZURE_OPENAI_ENDPOINT,
            api_key=AZURE_OPENAI_KEY,
            api_version=AZURE_OPENAI_API_VERSION,
        )
        self.deployment = AZURE_OPENAI_DEPLOYMENT
        self.playbook = playbook or default_playbook()
        self.channel = channel or self.playbook.channel_defaults.get("primary", "whatsapp")

    def run_turn(
        self,
        state: SalesState,
        user_message: str,
        memory: Dict[str, Any] | None = None,
        playbook: TenantPlaybook | None = None,
        channel: str | None = None,
        rag_facts: str | None = None,
    ) -> BrainOutput:
        active_playbook = playbook or self.playbook
        active_channel = channel or self.channel
        memory_for_prompt, meta = split_memory(memory)
        memory_for_prompt = dict(memory_for_prompt)

        inferred_os = infer_os_preference(user_message)
        if inferred_os and not memory_for_prompt.get("os_preference"):
            memory_for_prompt["os_preference"] = inferred_os

        budget_amount = max_budget_amount(
            [
                user_message,
                str(memory_for_prompt.get("budget_range") or ""),
                str(memory_for_prompt.get("budget_amount") or ""),
            ]
        )

        meta["turn_index"] = int(meta.get("turn_index") or 0) + 1

        signals = detect_signals(user_message, context=memory_for_prompt)
        if "close_intent" in signals:
            memory_for_prompt["committed"] = True

        deal_class = classify_deal(
            memory=memory_for_prompt,
            user_message=user_message,
            budget_amount=budget_amount,
            high_ticket_threshold=HIGH_BUDGET_THRESHOLD,
            vertical=active_playbook.vertical,
        )
        posture = choose_seller_posture(deal_class, memory_for_prompt.get("buyer_role"))
        tension_stage = next_tension_stage(
            current=meta.get("tension_stage"),
            signals=signals,
            memory=memory_for_prompt,
            posture=posture,
            sales_state=state,
        )
        decision_pressure = choose_decision_pressure(
            deal_class=deal_class,
            sales_state=state,
            signals=signals,
        )
        status_directive = build_status_directive(
            deal_class=deal_class,
            tension_stage=tension_stage,
            posture=posture,
            decision_pressure=decision_pressure,
        )
        meta["tension_stage"] = status_directive.tension_stage
        meta["seller_posture"] = status_directive.seller_posture
        meta["deal_class"] = status_directive.deal_class
        meta["disagreement_level"] = status_directive.disagreement_level
        meta["narrative_mode"] = status_directive.narrative_mode
        meta["friction_mode"] = status_directive.friction_mode
        meta["info_asymmetry"] = status_directive.info_asymmetry
        meta["decision_pressure"] = status_directive.decision_pressure

        supervisor_note, supervisor_flags = build_supervisor_note(
            user_message=user_message,
            memory=memory_for_prompt,
            budget_amount=budget_amount,
        )

        question_focus, suggested_question, recent_fields, friction_question = build_question_plan(
            state=state,
            memory=memory_for_prompt,
            meta=meta,
            user_message=user_message,
            budget_amount=budget_amount,
            signals=signals,
            status=status_directive,
        )
        question_plan = render_question_plan(
            question_focus=question_focus,
            suggested_question=suggested_question,
            recent_fields=recent_fields,
            friction_question=friction_question,
        )
        sensitive_domains = detect_sensitive_domains(
            [
                user_message,
                str(memory_for_prompt.get("use_case") or ""),
                str(memory_for_prompt.get("product") or ""),
            ]
        )
        guardrail_note = None
        guardrail_flags: List[str] = []
        if sensitive_domains:
            domain_list = ", ".join(sorted(set(sensitive_domains)))
            guardrail_note = (
                "Guardrails (must follow):\n"
                f"- Sensitive domain detected: {domain_list}.\n"
                "- Provide general information only; do NOT give financial, medical, or legal advice.\n"
                "- Encourage consulting a qualified professional when appropriate.\n"
                "- If uncertain, say so and ask a clarifying question instead of guessing."
            )
            guardrail_flags = [f"{d}_advisory_only" for d in sensitive_domains]
        messages = build_messages(
            playbook=active_playbook,
            state=state,
            user_message=user_message,
            memory=memory_for_prompt,
            channel=active_channel,
            rag_facts=rag_facts,
            supervisor_note=supervisor_note,
            question_plan=question_plan,
            status_directives=render_status_directives(status_directive),
            guardrail_note=guardrail_note,
        )

        temp = AZURE_OPENAI_TEMPERATURE
        req_kwargs = {
            "model": self.deployment,
            "messages": messages,
            "max_completion_tokens": AZURE_OPENAI_MAX_TOKENS,
        }
        if temp != 1.0:
            req_kwargs["temperature"] = temp

        resp = self.client.chat.completions.create(**req_kwargs)

        message = resp.choices[0].message
        raw = message.content or ""
        if not raw.strip():
            refusal = getattr(message, "refusal", None)
            if refusal:
                raw = refusal
        data = _parse_model_output(raw)

        extracted_dict = data.get("extracted", {}) or {}
        safety_flags = data.get("safety_flags", []) or []
        if supervisor_flags:
            safety_flags = list(dict.fromkeys(safety_flags + supervisor_flags))
        if guardrail_flags:
            safety_flags = list(dict.fromkeys(safety_flags + guardrail_flags))
        brain_data = data.get("brain")
        if brain_data:
            meta["brain"] = brain_data

        merged_extracted = dict(memory_for_prompt)
        for k, v in extracted_dict.items():
            if v is None or (isinstance(v, str) and not v.strip()) or v == []:
                continue
            merged_extracted[k] = v

        missing_fields = compute_missing(merged_extracted, state)
        next_state = next_state_logic(state, merged_extracted)

        lead_score = compute_lead_score(merged_extracted, next_state)

        extracted = Extracted(
            buyer_role=extracted_dict.get("buyer_role"),
            company=extracted_dict.get("company"),
            email=extracted_dict.get("email"),
            industry=extracted_dict.get("industry"),
            use_case=extracted_dict.get("use_case"),
            product=extracted_dict.get("product"),
            delivery_location=extracted_dict.get("delivery_location"),
            piece_id=extracted_dict.get("piece_id"),
            authenticity_facts=extracted_dict.get("authenticity_facts"),
            offer_category=extracted_dict.get("offer_category"),
            ml_intensity=extracted_dict.get("ml_intensity"),
            os_preference=extracted_dict.get("os_preference"),
            budget_range=extracted_dict.get("budget_range"),
            financial_sentiment=extracted_dict.get("financial_sentiment"),
            timeline=extracted_dict.get("timeline"),
            key_pain=extracted_dict.get("key_pain"),
            objections=extracted_dict.get("objections") or [],
            committed=bool(merged_extracted.get("committed")),
        )

        asked_fields = list(question_focus)
        if friction_question:
            asked_fields.append("friction")

        if asked_fields:
            last_asked = dict(meta.get("last_asked") or {})
            asked_counts = dict(meta.get("asked_counts") or {})
            for field in asked_fields:
                last_asked[field] = meta["turn_index"]
                asked_counts[field] = int(asked_counts.get(field) or 0) + 1
            meta["last_asked"] = last_asked
            meta["asked_counts"] = asked_counts

        return BrainOutput(
            reply=data.get("reply", "").strip(),
            next_state=next_state,
            extracted=extracted,
            missing_fields=missing_fields,
            lead_score=lead_score,
            safety_flags=safety_flags,
            meta=meta,
        )
