"""Flow Coach - Structured intent interview using Gemini Flash."""

from __future__ import annotations

import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field, ValidationError

from ..api.v2.flow_builder_schemas import (
    CoachDraft,
    CoachBranch,
    CoachAction,
    CoachQuestion,
    CoachResponse,
    CoachMessage,
    ConditionOperator,
    FlowCondition,
    SalesforceFieldType,
    ScoutContext,
    TriggerEvent,
)
from .. import settings
from .gemini_client import GeminiClient
from .llm_guard import ensure_routing_snapshot, save_routing_snapshot

log = logging.getLogger(__name__)

_FALLBACK_ERROR_TYPES = {
    "ResourceExhausted",
    "ServiceUnavailable",
    "DeadlineExceeded",
    "TimeoutError",
    "LLMCircuitOpenError",
}
_FALLBACK_PATTERNS = (
    "429",
    "rate limit",
    "ratelimit",
    "quota exceeded",
    "resource exhausted",
    "service unavailable",
    "503",
    "timeout",
    "deadline exceeded",
)


def _should_fallback(raw: Dict[str, Any]) -> bool:
    if not raw or not raw.get("_parse_error"):
        return False
    err_type = str(raw.get("_error_type") or "")
    if err_type in _FALLBACK_ERROR_TYPES:
        return True
    if err_type == "LLMConcurrencyError":
        return False
    haystack = f"{raw.get('_error') or ''} {raw.get('_raw') or ''}".lower()
    return any(token in haystack for token in _FALLBACK_PATTERNS)

COACH_SYSTEM_PROMPT = """You are the Flow Coach for FoundryOps.

You run a short, structured interview to convert user intent into a draft Flow specification.
Output ONLY valid JSON with this schema:

{
  "needs_more_info": true|false,
  "assistant_message": "Your conversational response to the user",
  "questions": [
    {
      "id": "object|trigger|conditions|actions|branches|overwrite_policy|record_type",
      "text": "question text",
      "choices": ["choice 1", "choice 2"]
    }
  ],
  "draft": {
    "object_api_name": "Lead",
    "record_type_id": null,
    "events": ["Create"] | ["Update"] | ["Create", "Update"],
    "logic": "AND" | "OR",
    "conditions": [
      {"field": "FieldApiName", "operator": "EqualTo|NotEqualTo|IsNull|IsNotNull|Contains|StartsWith|EndsWith|GreaterThan|GreaterOrEqual|LessThan|LessOrEqual", "value": "string|number|boolean|null", "value_is_field": false}
    ],
    "actions": [
      {"field": "FieldApiName", "value": "string|number|boolean", "value_is_field": false, "blank_only": true}
    ],
    "branches": [
      {
        "name": "Working when email sent",
        "logic": "AND" | "OR",
        "conditions": [
          {"field": "FieldApiName", "operator": "EqualTo|NotEqualTo|IsNull|IsNotNull|Contains|StartsWith|EndsWith|GreaterThan|GreaterOrEqual|LessThan|LessOrEqual", "value": "string|number|boolean|null", "value_is_field": false}
        ],
        "actions": [
          {"field": "FieldApiName", "value": "string|number|boolean", "value_is_field": false, "blank_only": true}
        ]
      }
    ],
    "overwrite_policy": "IF_BLANK|OVERWRITE"
  }
}

Rules:
- Ask only for missing pieces. Keep to 4-6 turns total.
- Use object, fields, picklists, and record types from context. Do NOT invent.
- Use exact field API names (case-sensitive). Do not prefix with $Record.
- You can ONLY update fields on the same record (Fast Field Updates).
- You CANNOT send emails, create tasks, post to chatter, or update related records.
- When asking for a picklist value, include available values in the question choices and mention them in assistant_message.
- If the user's value is not in the picklist, ask them to pick from the available values.
- For activity-based intent (email sent/received, reply, call made, meeting held), prefer activity fields from context (EAC_*, LastActivityDate, custom rollups). If ambiguous, ask which field to monitor and include 3-6 candidate fields in choices.
- If the user requests unsupported actions, set needs_more_info=true and explain the limitation.
- If overwrite behavior is unclear, ask whether to overwrite existing values or only set when blank.
- Default safety is "IF_BLANK" (blank_only=true) unless the user explicitly wants overwrite.
- Use branches when the user asks for different actions under different conditions.
- If you use branches, do NOT also use top-level actions.
- No nested decisions. Branching depth must be one level.
- Do NOT add per-action conditions inside actions.
- If the user says "also add..." or adds another scenario, append a new branch and keep existing branches.
- Order branches from most specific/advanced outcome to least; first match wins.
- For inactivity requests like "inactive for N days", use a numeric days-since field with GreaterOrEqual (e.g., EAC_Inactive_Days__c >= 180).
- If events include Update and there are no conditions, ask for conditions.
- If picklist values are record-type restricted and record_type_id is missing, ask for record type.
- When you have a complete draft, set needs_more_info=false and questions=[].

assistant_message Guidelines:
- Always include a short, friendly message acknowledging what the user said.
- When asking questions, briefly explain what you understood so far.
- When draft is ready (needs_more_info=false), confirm what the flow will do.
- Keep it concise (1-2 sentences max).
- Examples:
  - "Got it! I'll set LeadSource to 'Web' when a Lead is created."
  - "I see you want to update Status. When should this happen - on create, update, or both?"
  - "Your flow is ready! It will set Description to 'New lead from web' on Lead creation."

Branching example:
User: "Set status to Working when email sent, and Replied when they respond"
Your response:
{
  "needs_more_info": false,
  "assistant_message": "Got it — two branches: Working when email is sent, and Replied when they respond.",
  "questions": [],
  "draft": {
    "events": ["Update"],
    "branches": [
      {"name": "Working when email sent", "logic": "AND", "conditions": [{"field": "EAC_Last_Activity_Date__c", "operator": "IsNotNull", "value": null, "value_is_field": false}], "actions": [{"field": "Status", "value": "Working", "value_is_field": false, "blank_only": true}]},
      {"name": "Replied when email response", "logic": "AND", "conditions": [{"field": "EAC_Last_Reply_Date__c", "operator": "IsNotNull", "value": null, "value_is_field": false}], "actions": [{"field": "Status", "value": "Replied", "value_is_field": false, "blank_only": false}]}
    ]
  }
}

Return ONLY JSON. No extra text."""


class _CoachOutput(BaseModel):
    needs_more_info: bool = True
    assistant_message: Optional[str] = None
    questions: List[CoachQuestion] = Field(default_factory=list)
    draft: CoachDraft


@dataclass
class FlowCoachResult:
    response: CoachResponse
    raw: Dict[str, Any]


class FlowCoach:
    """Gemini-powered coach that returns structured draft data."""

    def __init__(self, model: Optional[str] = None, timeout_s: Optional[int] = None) -> None:
        resolved_model = (
            model
            or os.getenv("FLOW_COACH_MODEL")
            or os.getenv("FLOW_ARCHITECT_MODEL")
            or os.getenv("GEMINI_MODEL")
            or "gemini-3-flash-preview"
        )
        max_tokens = int(os.getenv("FLOW_COACH_MAX_OUTPUT_TOKENS", "2048"))
        timeout_s = timeout_s or settings.LLM_STANDARD_INTEL_TIMEOUT_S
        self._timeout_s = timeout_s
        self._max_tokens = max_tokens
        self._max_retries = 1
        self._budget_s = settings.LLM_STANDARD_INTEL_BUDGET_S
        self._retry_jitter_min_ms = settings.LLM_RETRY_JITTER_MIN_MS
        self._retry_jitter_max_ms = settings.LLM_RETRY_JITTER_MAX_MS
        self._fallback_model = settings.LLM_FALLBACK_MODEL
        self.client = GeminiClient(
            model=resolved_model,
            timeout_s=timeout_s,
            max_output_tokens=max_tokens,
            max_retries=self._max_retries,
            wall_clock_budget_s=self._budget_s,
            retry_jitter_min_ms=self._retry_jitter_min_ms,
            retry_jitter_max_ms=self._retry_jitter_max_ms,
        )
        self.model_used = resolved_model

    def _make_client(self, model: str) -> GeminiClient:
        return GeminiClient(
            model=model,
            timeout_s=self._timeout_s,
            max_output_tokens=self._max_tokens,
            max_retries=self._max_retries,
            wall_clock_budget_s=self._budget_s,
            retry_jitter_min_ms=self._retry_jitter_min_ms,
            retry_jitter_max_ms=self._retry_jitter_max_ms,
        )

    def _resolve_snapshot(self, conversation_id: Optional[str], primary_model: str):
        if not conversation_id:
            return None
        key = f"llm:session:{conversation_id}"
        return ensure_routing_snapshot(
            key,
            primary_model=primary_model,
            fallback_model=self._fallback_model,
            ttl_s=86400,
            tier="flow_coach",
        )

    def _apply_fallback_snapshot(
        self, conversation_id: Optional[str], snapshot
    ) -> None:
        if not conversation_id or not snapshot:
            return
        key = f"llm:session:{conversation_id}"
        save_routing_snapshot(key, snapshot, ttl_s=86400)

    def coach(
        self,
        *,
        message: str,
        history: List[CoachMessage],
        draft: Optional[CoachDraft],
        context: Optional[ScoutContext],
        object_api_name: Optional[str],
        record_type_id: Optional[str],
        conversation_id: Optional[str] = None,
        account_id: Optional[str] = None,
    ) -> FlowCoachResult:
        user_prompt = self._build_user_prompt(
            message=message,
            history=history,
            draft=draft,
            context=context,
            object_api_name=object_api_name,
            record_type_id=record_type_id,
        )
        messages = [
            {"role": "system", "content": COACH_SYSTEM_PROMPT},
            {"role": "user", "content": user_prompt},
        ]

        fallback_model = getattr(self, "_fallback_model", None)
        snapshot = self._resolve_snapshot(conversation_id, self.model_used)
        use_fallback = bool(snapshot and snapshot.using_fallback and fallback_model)
        model_used = fallback_model if use_fallback else self.model_used
        client = self.client if not use_fallback else self._make_client(fallback_model)

        raw = client.chat_json(messages, retry_strict=True, account_id=account_id)
        if raw.get("_parse_error") and not raw.get("_error_type"):
            raw = client.chat_json(messages, retry_strict=True, account_id=account_id)

        if (
            raw.get("_parse_error")
            and not use_fallback
            and fallback_model
            and _should_fallback(raw)
        ):
            if snapshot:
                snapshot.using_fallback = True
                self._apply_fallback_snapshot(conversation_id, snapshot)
            fallback_client = self._make_client(fallback_model)
            raw = fallback_client.chat_json(
                messages, retry_strict=True, account_id=account_id
            )
            if raw.get("_parse_error") and not raw.get("_error_type"):
                raw = fallback_client.chat_json(
                messages, retry_strict=True, account_id=account_id
            )
            model_used = fallback_model

        if raw.get("_parse_error"):
            err = raw.get("_error") or ""
            if "JSON parse failed" in err or "json_parse_error" in err:
                err = ""
            fallback = self._fallback_response(
                message, draft, context, history, record_type_id
            )
            if fallback:
                return FlowCoachResult(response=fallback, raw=raw)
            response = CoachResponse(
                success=False,
                needs_more_info=True,
                questions=[],
                draft=draft,
                error=err
                or "I couldn't structure that request. Try describing one automation at a time.",
                model_used=model_used,
            )
            return FlowCoachResult(response=response, raw=raw)

        try:
            output = _CoachOutput.model_validate(raw)
        except ValidationError as exc:
            fallback = self._fallback_response(
                message, draft, context, history, record_type_id
            )
            if fallback:
                return FlowCoachResult(response=fallback, raw=raw)
            response = CoachResponse(
                success=False,
                needs_more_info=True,
                questions=[],
                draft=draft,
                error=str(exc),
                model_used=model_used,
            )
            return FlowCoachResult(response=response, raw=raw)

        final_draft = output.draft
        if object_api_name and not final_draft.object_api_name:
            final_draft = final_draft.model_copy(
                update={"object_api_name": object_api_name}
            )
        if record_type_id and not final_draft.record_type_id:
            final_draft = final_draft.model_copy(
                update={"record_type_id": record_type_id}
            )

        response = CoachResponse(
            success=True,
            needs_more_info=output.needs_more_info,
            questions=output.questions,
            draft=final_draft,
            assistant_message=output.assistant_message,
            error=None,
            model_used=model_used,
        )
        response = self._postprocess_response(
            response=response,
            message=message,
            history=history,
            context=context,
            record_type_id=record_type_id,
        )
        return FlowCoachResult(response=response, raw=raw)

    def _fallback_response(
        self,
        message: str,
        draft: Optional[CoachDraft],
        context: Optional[ScoutContext],
        history: Optional[List[CoachMessage]] = None,
        record_type_id: Optional[str] = None,
    ) -> Optional[CoachResponse]:
        if not message:
            return None
        low = message.lower()
        history_text = " ".join(
            [m.content for m in (history or []) if m and m.content]
        ).lower()
        effective_record_type_id = record_type_id or (
            draft.record_type_id if draft else None
        )

        inactivity_days = self._parse_inactivity_days(low) or self._parse_inactivity_days(
            history_text
        )
        inactivity_field = self._resolve_field_from_message(message, context)
        if not inactivity_field and history:
            for msg in reversed(history):
                if not msg or not msg.content:
                    continue
                candidate = self._resolve_field_from_message(msg.content, context)
                if candidate and self._is_numeric_field(context, candidate):
                    inactivity_field = candidate
                    break

        history_has_inactivity = self._is_inactivity_request(history_text)
        if self._is_inactivity_request(low) or (inactivity_days and history_has_inactivity):
            return self._handle_inactivity_fallback(
                draft=draft,
                context=context,
                inactivity_days=inactivity_days,
                inactivity_field=inactivity_field,
                message_text=low,
                history_text=history_text,
                record_type_id=effective_record_type_id,
            )

        questions: List[CoachQuestion] = []
        assistant_message: Optional[str] = None

        if "status" in low:
            status_choices = self._extract_status_choices(
                context, effective_record_type_id
            )
            # Use word boundary to avoid false positives (e.g., "cold" in "household")
            matched_statuses = [
                val for val in status_choices
                if val and re.search(r'\b' + re.escape(val.lower()) + r'\b', low)
            ]
            has_or = " or " in low or " either " in low
            if status_choices and (len(matched_statuses) == 0 or (has_or and len(matched_statuses) <= 1)):
                preview = self._format_choice_preview(status_choices, 6)
                question_text = "What should the Lead Status be set to?"
                if preview:
                    question_text += f" Available values: {preview}."
                questions.append(
                    CoachQuestion(
                        id="actions",
                        text=question_text,
                        choices=status_choices[:6],
                    )
                )
                assistant_message = self._append_choice_hint(
                    "I can help update Lead Status—what exact status value should I use?",
                    "Lead Status",
                    status_choices,
                    6,
                )

        if ("email" in low or "activity" in low) and not self._mentions_field_name(
            low, context
        ):
            intent = self._detect_activity_intent(low) or self._detect_activity_intent(
                history_text
            )
            choices = self._suggest_activity_fields(context, intent)
            if intent == "email_sent":
                question_text = "Which field should indicate an email was sent?"
            elif intent == "email_received":
                question_text = "Which field should indicate an email was received or replied to?"
            elif intent == "call":
                question_text = "Which field should indicate a call was completed?"
            elif intent == "meeting":
                question_text = "Which field should indicate a meeting occurred?"
            else:
                question_text = "Which field should indicate the email activity?"
            questions.append(
                CoachQuestion(
                    id="conditions",
                    text=question_text,
                    choices=choices,
                )
            )
            if not assistant_message:
                assistant_message = (
                    "Which field should I monitor to detect that activity?"
                )

        if not questions:
            return None

        return CoachResponse(
            success=True,
            needs_more_info=True,
            questions=questions,
            draft=draft,
            assistant_message=assistant_message,
            error=None,
            model_used=self.model_used,
        )

    @staticmethod
    def _is_inactivity_request(text: str) -> bool:
        if not text:
            return False
        if (
            "no " in text
            or "without " in text
            or "not been" in text
            or "has not" in text
            or "have not" in text
            or "haven't" in text
            or "hasn't" in text
        ):
            if ("day" in text or "week" in text or "month" in text) and (
                "activity" in text
                or "email" in text
                or "emails" in text
                or "communication" in text
                or "touch" in text
                or "reply" in text
                or "response" in text
            ):
                return True
        return bool(
            re.search(
                r"(no|without|not been|has not|have not|haven't|hasn't).*(activity|email|emails|communication|touch|reply|response).*(\d+)\s*(day|week|month)",
                text,
                re.IGNORECASE,
            )
        )

    @staticmethod
    def _parse_inactivity_days(text: str) -> Optional[int]:
        if not text:
            return None
        match = re.search(r"(\d+)\s*(day|days|d|week|weeks|month|months)", text, re.IGNORECASE)
        if not match:
            return None
        num = int(match.group(1))
        unit = match.group(2).lower()
        if unit.startswith("week"):
            return num * 7
        if unit.startswith("month"):
            return num * 30
        return num

    def _handle_inactivity_fallback(
        self,
        *,
        draft: Optional[CoachDraft],
        context: Optional[ScoutContext],
        inactivity_days: Optional[int],
        inactivity_field: Optional[str],
        message_text: str,
        history_text: str,
        record_type_id: Optional[str],
    ) -> Optional[CoachResponse]:
        status_field = self._resolve_status_field(draft, context)
        status_choices = self._extract_status_choices(context, record_type_id)
        status_value = self._extract_status_value(
            message_text, context, record_type_id
        ) or self._extract_status_value(history_text, context, record_type_id)
        if not status_value:
            status_literal = self._extract_status_literal(message_text) or self._extract_status_literal(
                history_text
            )
            if status_literal:
                if status_choices:
                    for val in status_choices:
                        if val.lower() == status_literal.lower():
                            status_value = val
                            break
                else:
                    status_value = status_literal.title()
        if status_value and status_choices and status_value not in status_choices:
            status_value = None

        if inactivity_field and not self._is_numeric_field(context, inactivity_field):
            inactivity_field = None

        if inactivity_field and inactivity_days and status_field and status_value:
            updated = self._append_inactivity_branch(
                draft=draft,
                field=inactivity_field,
                days=inactivity_days,
                status_field=status_field,
                status_value=status_value,
            )
            if updated:
                return CoachResponse(
                    success=True,
                    needs_more_info=False,
                    questions=[],
                    draft=updated,
                    assistant_message=(
                        f"Got it. I'll add a branch to set {status_value} after {inactivity_days} days of inactivity."
                    ),
                    error=None,
                    model_used=self.model_used,
                )

        questions: List[CoachQuestion] = []
        if not inactivity_field:
            choices = self._suggest_inactivity_fields(context)
            questions.append(
                CoachQuestion(
                    id="conditions",
                    text="Which numeric field represents days since last activity?",
                    choices=choices,
                )
            )
        if not inactivity_days:
            questions.append(
                CoachQuestion(
                    id="conditions",
                    text="How many days of inactivity should trigger this?",
                    choices=["30", "60", "90", "180"],
                )
            )
        if not status_value:
            status_choices = self._extract_status_choices(context, record_type_id)
            preview = self._format_choice_preview(status_choices, 6)
            question_text = "What status value should be set for inactivity?"
            if preview:
                question_text += f" Available values: {preview}."
            questions.append(
                CoachQuestion(
                    id="actions",
                    text=question_text,
                    choices=status_choices[:6],
                )
            )

        if not questions:
            return None

        return CoachResponse(
            success=True,
            needs_more_info=True,
            questions=questions,
            draft=draft,
            assistant_message=(
                "I can add an inactivity branch. I just need the days, field, and status value."
            ),
            error=None,
            model_used=self.model_used,
        )

    def _postprocess_response(
        self,
        *,
        response: CoachResponse,
        message: str,
        history: List[CoachMessage],
        context: Optional[ScoutContext],
        record_type_id: Optional[str],
    ) -> CoachResponse:
        if not response or not response.success or not context:
            return response
        draft = response.draft
        if not draft:
            return response

        effective_record_type_id = record_type_id or draft.record_type_id
        history_text = " ".join(
            [m.content for m in (history or []) if m and m.content]
        ).lower()
        message_text = (message or "").lower()

        if response.questions:
            return self._enrich_questions_with_choices(
                response=response,
                message_text=message_text,
                history_text=history_text,
                context=context,
                record_type_id=effective_record_type_id,
            )

        invalid = self._find_invalid_picklist_value(
            draft=draft,
            context=context,
            record_type_id=effective_record_type_id,
        )
        if invalid:
            return self._build_picklist_question(
                response=response,
                info=invalid,
                context=context,
            )

        status_question = self._build_status_question_if_needed(
            response=response,
            message_text=message_text,
            history_text=history_text,
            draft=draft,
            context=context,
            record_type_id=effective_record_type_id,
        )
        if status_question:
            return status_question

        activity_question = self._build_activity_question(
            response=response,
            message_text=message_text,
            history_text=history_text,
            draft=draft,
            context=context,
        )
        if activity_question:
            return activity_question

        return response

    @staticmethod
    def _mentions_any_value(text: str, values: List[str]) -> bool:
        if not text or not values:
            return False
        for val in values:
            if val and val.lower() in text:
                return True
        return False

    @staticmethod
    def _format_choice_preview(values: List[str], max_items: int = 6) -> str:
        if not values:
            return ""
        cleaned = [str(v) for v in values if v]
        if not cleaned:
            return ""
        shown = cleaned[:max_items]
        if len(cleaned) > max_items:
            return ", ".join(shown) + f" (+{len(cleaned) - max_items} more)"
        return ", ".join(shown)

    @staticmethod
    def _append_choice_hint(
        message: Optional[str], label: str, choices: List[str], max_items: int = 6
    ) -> Optional[str]:
        preview = FlowCoach._format_choice_preview(choices, max_items)
        if not preview:
            return message
        hint = f"Available {label} values: {preview}."
        if message:
            if hint in message:
                return message
            return f"{message} {hint}"
        return hint

    @staticmethod
    def _field_label(context: Optional[ScoutContext], field: Optional[str]) -> str:
        if not field:
            return "that field"
        if not context:
            return field
        meta = context.get_field(field)
        if meta and meta.label and meta.label != field:
            return meta.label
        return field

    @staticmethod
    def _detect_activity_intent(text: str) -> Optional[str]:
        if not text:
            return None
        low = text.lower()
        if "email" in low or "e-mail" in low:
            if any(tok in low for tok in ("reply", "replied", "respond", "response", "received", "inbound")):
                return "email_received"
            if any(tok in low for tok in ("sent", "send", "sending", "outbound")):
                return "email_sent"
        if any(tok in low for tok in ("reply", "replied", "respond", "response")):
            return "email_received"
        if re.search(r"\b(call|called|calling|phone|voicemail)\b", low):
            return "call"
        if re.search(r"\b(meeting|demo|appointment)\b", low):
            return "meeting"
        if re.search(r"\b(activity|engagement|touch|interaction)\b", low):
            return "activity"
        return None

    @staticmethod
    def _field_matches_activity_intent(
        name: str, label: str, intent: Optional[str]
    ) -> bool:
        low = f"{name} {label}".lower()
        if intent == "email_sent":
            return "email" in low and ("sent" in low or "outbound" in low)
        if intent == "email_received":
            if "email" in low and (
                "received" in low or "reply" in low or "response" in low or "inbound" in low
            ):
                return True
            return "reply" in low or "response" in low
        if intent == "call":
            return "call" in low or "phone" in low or "voicemail" in low
        if intent == "meeting":
            return "meeting" in low or "demo" in low or "appointment" in low or "calendar" in low
        return any(
            tok in low
            for tok in (
                "activity",
                "eac",
                "email",
                "call",
                "meeting",
                "task",
                "event",
                "touch",
                "engagement",
                "interaction",
                "lastcontact",
            )
        )

    @staticmethod
    def _activity_field_score(
        name: str, label: str, field_type: SalesforceFieldType, intent: Optional[str]
    ) -> int:
        low = f"{name} {label}".lower()
        score = 0
        if low.startswith("eac_") or low.startswith("eac "):
            score += 6
        if "eac" in low:
            score += 4
        if "last" in low:
            score += 2
        if field_type in (SalesforceFieldType.DATE, SalesforceFieldType.DATETIME):
            score += 2
        if FlowCoach._field_matches_activity_intent(name, label, intent):
            score += 3
        if intent == "email_sent" and ("sent" in low or "outbound" in low):
            score += 2
        if intent == "email_received" and (
            "received" in low or "reply" in low or "response" in low or "inbound" in low
        ):
            score += 2
        if intent == "call" and ("call" in low or "phone" in low or "voicemail" in low):
            score += 2
        if intent == "meeting" and (
            "meeting" in low or "demo" in low or "appointment" in low
        ):
            score += 2
        return score

    @staticmethod
    def _suggest_activity_fields(
        context: Optional[ScoutContext], intent: Optional[str] = None
    ) -> List[str]:
        if not context or not context.fields:
            return []

        def _collect(intent_hint: Optional[str]) -> List[str]:
            scored: List[tuple[int, str]] = []
            for name, meta in context.fields.items():
                label = meta.label or ""
                if not FlowCoach._field_matches_activity_intent(name, label, intent_hint):
                    continue
                score = FlowCoach._activity_field_score(
                    name, label, meta.type, intent_hint
                )
                scored.append((score, name))
            scored.sort(key=lambda item: (-item[0], item[1].lower()))
            result: List[str] = []
            for _, name in scored:
                if name in result:
                    continue
                result.append(name)
                if len(result) >= 6:
                    break
            return result

        candidates = _collect(intent)
        if not candidates and intent:
            candidates = _collect(None)
        return candidates

    @staticmethod
    def _draft_mentions_activity_field(
        draft: Optional[CoachDraft], context: Optional[ScoutContext]
    ) -> bool:
        if not draft or not context:
            return False
        fields: List[str] = []
        if draft.conditions:
            fields.extend([c.field for c in draft.conditions if c and c.field])
        if draft.branches:
            for branch in draft.branches:
                if branch and branch.conditions:
                    fields.extend([c.field for c in branch.conditions if c and c.field])
        for field in fields:
            meta = context.get_field(field)
            label = meta.label if meta else ""
            if FlowCoach._field_matches_activity_intent(field, label, None):
                return True
        return False

    @staticmethod
    def _mentions_field_name(text: str, context: Optional[ScoutContext]) -> bool:
        if not text or not context or not context.fields:
            return False
        low = text.lower()
        generic_fields = {
            "email",
            "phone",
            "name",
            "firstname",
            "lastname",
            "status",
            "company",
            "title",
        }
        for name in context.fields.keys():
            if not name:
                continue
            name_low = name.lower()
            if name_low in generic_fields:
                continue
            if re.search(rf"\b{re.escape(name_low)}\b", low):
                return True
        return False

    @staticmethod
    def _resolve_field_from_message(
        message: str, context: Optional[ScoutContext]
    ) -> Optional[str]:
        """Extract field name from message by tokenizing and checking each token."""
        if not message or not context:
            return None
        # Tokenize message and check each token against known field names
        for token in message.split():
            # Strip common punctuation
            clean_token = token.strip(".,;:!?()[]\"'")
            if not clean_token:
                continue
            canonical = context.get_canonical_name(clean_token)
            if canonical:
                return canonical
        return None

    @staticmethod
    def _is_numeric_field(context: Optional[ScoutContext], field: Optional[str]) -> bool:
        if not context or not field:
            return False
        meta = context.get_field(field)
        if not meta:
            return False
        return meta.type in (
            SalesforceFieldType.INTEGER,
            SalesforceFieldType.DOUBLE,
            SalesforceFieldType.CURRENCY,
            SalesforceFieldType.PERCENT,
        )

    def _enrich_questions_with_choices(
        self,
        *,
        response: CoachResponse,
        message_text: str,
        history_text: str,
        context: ScoutContext,
        record_type_id: Optional[str],
    ) -> CoachResponse:
        if not response.questions:
            return response
        q = response.questions[0]
        if q.choices:
            return response

        draft = response.draft
        question_text = q.text or ""
        assistant_message = response.assistant_message

        if q.id == "actions" and (
            "status" in message_text
            or "status" in question_text.lower()
            or (draft and self._resolve_status_field(draft, context))
        ):
            status_choices = self._extract_status_choices(context, record_type_id)
            if status_choices:
                status_field = self._resolve_status_field(draft, context) or "Status"
                label = self._field_label(context, status_field)
                preview = self._format_choice_preview(status_choices, 6)
                if preview and preview not in question_text:
                    question_text = (question_text or f"Which {label} value should I use?").strip()
                    question_text += f" Available values: {preview}."
                assistant_message = self._append_choice_hint(
                    assistant_message, label, status_choices, 6
                )
                updated = CoachQuestion(
                    id=q.id,
                    text=question_text or q.text,
                    choices=status_choices[:8],
                )
                return response.model_copy(
                    update={"questions": [updated], "assistant_message": assistant_message}
                )

        if q.id == "conditions":
            intent = self._detect_activity_intent(message_text) or self._detect_activity_intent(
                history_text
            )
            if intent:
                choices = self._suggest_activity_fields(context, intent)
                if choices:
                    assistant_message = assistant_message or (
                        "Which field should I monitor for that activity?"
                    )
                    updated = CoachQuestion(
                        id=q.id,
                        text=question_text or q.text or "Which field should indicate that activity?",
                        choices=choices,
                    )
                    return response.model_copy(
                        update={
                            "questions": [updated],
                            "assistant_message": assistant_message,
                        }
                    )

        return response

    def _iter_coach_actions(self, draft: CoachDraft) -> List[CoachAction]:
        actions: List[CoachAction] = []
        if draft.actions:
            actions.extend(draft.actions)
        if draft.branches:
            for branch in draft.branches:
                if branch and branch.actions:
                    actions.extend(branch.actions)
        return actions

    def _iter_coach_conditions(self, draft: CoachDraft) -> List[FlowCondition]:
        conditions: List[FlowCondition] = []
        if draft.conditions:
            conditions.extend(draft.conditions)
        if draft.branches:
            for branch in draft.branches:
                if branch and branch.conditions:
                    conditions.extend(branch.conditions)
        return conditions

    def _find_invalid_picklist_value(
        self,
        *,
        draft: CoachDraft,
        context: ScoutContext,
        record_type_id: Optional[str],
    ) -> Optional[Dict[str, Any]]:
        def normalize_value(
            value: str, choices: List[str], multi: bool
        ) -> Optional[str]:
            if not choices:
                return None
            lower_map = {str(v).lower(): str(v) for v in choices if v}
            if not multi:
                if value in choices:
                    return value
                return lower_map.get(value.lower())
            parts = [p.strip() for p in value.split(";") if p.strip()]
            if not parts:
                return value
            new_parts: List[str] = []
            for part in parts:
                if part in choices:
                    new_parts.append(part)
                    continue
                canonical = lower_map.get(part.lower())
                if not canonical:
                    return None
                new_parts.append(canonical)
            return ";".join(new_parts)

        for action in self._iter_coach_actions(draft):
            if action.value_is_field or action.value is None:
                continue
            if not isinstance(action.value, str):
                continue
            meta = context.get_field(action.field)
            if not meta or meta.type not in (
                SalesforceFieldType.PICKLIST,
                SalesforceFieldType.MULTIPICKLIST,
            ):
                continue
            choices = context.get_picklist_values(action.field, record_type_id)
            if choices is None:
                if action.field in context.picklist_restrictions:
                    return {
                        "field": action.field,
                        "value": action.value,
                        "choices": None,
                        "record_type_needed": True,
                        "question_id": "actions",
                    }
                continue
            normalized = normalize_value(
                action.value,
                list(choices),
                meta.type == SalesforceFieldType.MULTIPICKLIST,
            )
            if normalized is None:
                return {
                    "field": action.field,
                    "value": action.value,
                    "choices": [str(v) for v in choices if v],
                    "record_type_needed": False,
                    "question_id": "actions",
                }
            if normalized != action.value:
                action.value = normalized

        for condition in self._iter_coach_conditions(draft):
            if condition.value is None or condition.value_is_field:
                continue
            if not isinstance(condition.value, str):
                continue
            meta = context.get_field(condition.field)
            if not meta or meta.type not in (
                SalesforceFieldType.PICKLIST,
                SalesforceFieldType.MULTIPICKLIST,
            ):
                continue
            choices = context.get_picklist_values(condition.field, record_type_id)
            if choices is None:
                if condition.field in context.picklist_restrictions:
                    return {
                        "field": condition.field,
                        "value": condition.value,
                        "choices": None,
                        "record_type_needed": True,
                        "question_id": "conditions",
                    }
                continue
            normalized = normalize_value(
                condition.value,
                list(choices),
                meta.type == SalesforceFieldType.MULTIPICKLIST,
            )
            if normalized is None:
                return {
                    "field": condition.field,
                    "value": condition.value,
                    "choices": [str(v) for v in choices if v],
                    "record_type_needed": False,
                    "question_id": "conditions",
                }
            if normalized != condition.value:
                condition.value = normalized

        return None

    def _build_picklist_question(
        self,
        *,
        response: CoachResponse,
        info: Dict[str, Any],
        context: ScoutContext,
    ) -> CoachResponse:
        field = info.get("field")
        label = self._field_label(context, field)
        choices = info.get("choices") or []
        question_id = info.get("question_id") or "actions"
        value = info.get("value")
        record_type_needed = bool(info.get("record_type_needed"))

        assistant_message: Optional[str] = None
        question_text = ""

        if record_type_needed:
            rt_choices = [rt.name for rt in context.record_types[:6]]
            question_text = f"Picklist values for {label} depend on record type. Which record type should I use?"
            assistant_message = (
                "Picklist values are record type specific. Select one above or choose it in the Record Type step."
            )
            assistant_message = self._append_choice_hint(
                assistant_message, "record type", rt_choices, 6
            )
            return CoachResponse(
                success=response.success,
                needs_more_info=True,
                questions=[
                    CoachQuestion(
                        id="record_type",
                        text=question_text,
                        choices=rt_choices,
                    )
                ],
                draft=response.draft,
                assistant_message=assistant_message,
                error=response.error,
                model_used=response.model_used,
            )

        if value:
            assistant_message = f"'{value}' isn't an available {label} value."
        question_text = f"Which {label} value should I use?"
        if choices:
            preview = self._format_choice_preview(choices, 6)
            if preview:
                question_text += f" Available values: {preview}."
            assistant_message = self._append_choice_hint(
                assistant_message, label, choices, 6
            )
        else:
            if not assistant_message:
                assistant_message = f"Which {label} value should I use?"

        return CoachResponse(
            success=response.success,
            needs_more_info=True,
            questions=[
                CoachQuestion(
                    id=question_id,
                    text=question_text,
                    choices=choices[:8],
                )
            ],
            draft=response.draft,
            assistant_message=assistant_message,
            error=response.error,
            model_used=response.model_used,
        )

    def _build_status_question_if_needed(
        self,
        *,
        response: CoachResponse,
        message_text: str,
        history_text: str,
        draft: CoachDraft,
        context: ScoutContext,
        record_type_id: Optional[str],
    ) -> Optional[CoachResponse]:
        if not response.needs_more_info:
            return None
        if "status" not in message_text and "status" not in history_text:
            return None
        status_field = self._resolve_status_field(draft, context)
        if not status_field:
            for name in ("Status", "LeadStatus"):
                if name in context.fields:
                    status_field = name
                    break
        if not status_field:
            return None
        choices = context.get_picklist_values(status_field, record_type_id) or []
        if not choices:
            return None
        info = {
            "field": status_field,
            "value": None,
            "choices": [str(v) for v in choices if v],
            "record_type_needed": False,
            "question_id": "actions",
        }
        return self._build_picklist_question(
            response=response,
            info=info,
            context=context,
        )

    def _build_activity_question(
        self,
        *,
        response: CoachResponse,
        message_text: str,
        history_text: str,
        draft: CoachDraft,
        context: ScoutContext,
    ) -> Optional[CoachResponse]:
        intent = self._detect_activity_intent(message_text) or self._detect_activity_intent(
            history_text
        )
        if not intent:
            return None
        if self._mentions_field_name(message_text, context):
            return None
        if self._draft_mentions_activity_field(draft, context):
            return None

        choices = self._suggest_activity_fields(context, intent)
        if intent == "email_sent":
            question_text = "Which field should indicate an email was sent?"
        elif intent == "email_received":
            question_text = "Which field should indicate an email was received or replied to?"
        elif intent == "call":
            question_text = "Which field should indicate a call was completed?"
        elif intent == "meeting":
            question_text = "Which field should indicate a meeting occurred?"
        else:
            question_text = "Which field should indicate that activity?"

        assistant_message = (
            "I can use Einstein Activity Capture fields (EAC_*), task/event rollups like LastActivityDate, or a custom rollup field."
        )
        if choices:
            assistant_message = self._append_choice_hint(
                assistant_message, "field", choices, 6
            )
        else:
            assistant_message += " Please tell me the exact field API name to monitor."

        return CoachResponse(
            success=response.success,
            needs_more_info=True,
            questions=[
                CoachQuestion(id="conditions", text=question_text, choices=choices)
            ],
            draft=response.draft,
            assistant_message=assistant_message,
            error=response.error,
            model_used=response.model_used,
        )

    def _resolve_status_field(
        self, draft: Optional[CoachDraft], context: Optional[ScoutContext]
    ) -> Optional[str]:
        if draft:
            if draft.actions:
                return draft.actions[0].field
            if draft.branches:
                for branch in draft.branches:
                    if branch.actions:
                        return branch.actions[0].field
        if context:
            for name in ("Status", "LeadStatus"):
                if name in context.fields:
                    return name
        return None

    @staticmethod
    def _extract_status_value(
        text: str,
        context: Optional[ScoutContext],
        record_type_id: Optional[str],
    ) -> Optional[str]:
        if not text or not context:
            return None
        values = FlowCoach._extract_status_choices(context, record_type_id)
        for val in values:
            # Use word boundary to avoid false positives (e.g., "cold" in "household")
            if re.search(r'\b' + re.escape(val.lower()) + r'\b', text):
                return val
        return None

    @staticmethod
    def _extract_status_literal(text: str) -> Optional[str]:
        if not text:
            return None
        low = text.lower()
        patterns = [
            r"(?:lead status|status)\s*(?:should be|should|to|as|=|is|set to)?\s*['\"]?([a-z][a-z0-9 _-]{0,40}?)(?:\s+(?:if|when|once|after|because|so)\b|$|[.,;])",
            r"(?:set|update|change)\s+(?:the\s+)?status\s+(?:to|as)?\s*['\"]?([a-z][a-z0-9 _-]{0,40}?)(?:\s+(?:if|when|once|after|because|so)\b|$|[.,;])",
        ]
        for pattern in patterns:
            match = re.search(pattern, low)
            if match:
                value = match.group(1).strip().strip(" .,!?:;")
                if " or " in value or " and " in value:
                    return None
                return value
        return None

    def _append_inactivity_branch(
        self,
        *,
        draft: Optional[CoachDraft],
        field: str,
        days: int,
        status_field: str,
        status_value: str,
    ) -> Optional[CoachDraft]:
        if not draft:
            return None

        condition = FlowCondition(
            field=field,
            operator=ConditionOperator.GREATER_OR_EQUAL,
            value=days,
            value_is_field=False,
        )
        action = CoachAction(
            field=status_field,
            value=status_value,
            value_is_field=False,
            blank_only=True,
        )
        branch_name = f"{status_value} after {days} days inactive"
        new_branch = CoachBranch(
            name=branch_name,
            logic="AND",
            conditions=[condition],
            actions=[action],
        )

        if draft.branches:
            return draft.model_copy(
                update={"branches": draft.branches + [new_branch], "actions": []}
            )

        if draft.actions:
            existing_branch = CoachBranch(
                name="Existing branch",
                logic=draft.logic,
                conditions=draft.conditions or [],
                actions=draft.actions or [],
            )
            return draft.model_copy(
                update={
                    "branches": [existing_branch, new_branch],
                    "actions": [],
                    "conditions": [],
                }
            )

        return draft.model_copy(update={"branches": [new_branch]})

    @staticmethod
    def _extract_status_choices(
        context: Optional[ScoutContext], record_type_id: Optional[str]
    ) -> List[str]:
        if not context or not context.fields:
            return []
        status_field = None
        for name in ("Status", "LeadStatus"):
            if name in context.fields:
                status_field = name
                break
        if not status_field:
            return []
        values = context.get_picklist_values(status_field, record_type_id) or []
        return [str(v) for v in values if v][:10]

    @staticmethod
    def _suggest_inactivity_fields(context: Optional[ScoutContext]) -> List[str]:
        if not context or not context.fields:
            return []
        candidates = []
        for name, field in context.fields.items():
            low = name.lower()
            if field.type in (
                SalesforceFieldType.INTEGER,
                SalesforceFieldType.DOUBLE,
                SalesforceFieldType.CURRENCY,
                SalesforceFieldType.PERCENT,
            ):
                if "inactive" in low or "day" in low or "days" in low or "since" in low:
                    candidates.append(name)
        if candidates:
            return candidates[:6]
        # Fallback: return first few numeric fields
        for name, field in context.fields.items():
            if field.type in (
                SalesforceFieldType.INTEGER,
                SalesforceFieldType.DOUBLE,
                SalesforceFieldType.CURRENCY,
                SalesforceFieldType.PERCENT,
            ):
                candidates.append(name)
            if len(candidates) >= 6:
                break
        return candidates

    def _build_user_prompt(
        self,
        *,
        message: str,
        history: List[CoachMessage],
        draft: Optional[CoachDraft],
        context: Optional[ScoutContext],
        object_api_name: Optional[str],
        record_type_id: Optional[str],
    ) -> str:
        sections: List[str] = []
        if object_api_name:
            sections.append(f"Object: {object_api_name}")
        if record_type_id:
            sections.append(f"Selected RecordTypeId: {record_type_id}")
        if draft:
            sections.append(f"Current Draft: {draft.model_dump(mode='json')}")
        if history:
            lines = []
            for msg in history[-6:]:
                role = "User" if msg.role == "user" else "Coach"
                lines.append(f"{role}: {msg.content}")
            sections.append("Conversation:\n" + "\n".join(lines))

        sections.append(f"User Message: {message}")

        if context:
            sections.append(self._format_context(context, record_type_id))
        return "\n\n".join(sections)

    def _format_context(
        self, context: ScoutContext, record_type_id: Optional[str]
    ) -> str:
        lines: List[str] = []
        lines.append(f"Object Label: {context.object_label}")

        if context.record_types:
            rt_lines = []
            for rt in context.record_types[:10]:
                flag = " (default)" if rt.is_default else ""
                rt_lines.append(f"{rt.name} [{rt.id}]{flag}")
            lines.append("Record Types: " + "; ".join(rt_lines))

        picklists = []
        for name, field in context.fields.items():
            if field.type in (
                SalesforceFieldType.PICKLIST,
                SalesforceFieldType.MULTIPICKLIST,
            ):
                values = context.get_picklist_values(name, record_type_id)
                if values:
                    picklists.append(f"- {name}: {values[:12]}")
                else:
                    picklists.append(f"- {name}: (values require record type)")
        if picklists:
            lines.append("Picklist Values:\n" + "\n".join(picklists[:50]))

        field_lines = []
        for idx, field in enumerate(context.fields.values()):
            if idx >= 200:
                field_lines.append("... (truncated)")
                break
            field_lines.append(f"- {field.name} ({field.type})")
        lines.append("Fields:\n" + "\n".join(field_lines))

        return "\n".join(lines)
