import re
from typing import Optional
import spacy

from clm_core.dictionary.en.patterns import (
    SYSTEM_ACTION_KEYWORDS,
    ISSUE_TO_INTENT,
    ISSUE_TO_SERVICE,
    ISSUE_TO_DOMAIN,
    CALL_TYPE_TO_DOMAIN,
    CUSTOMER_INTENT_KEYWORDS,
    EXPLICIT_AGENT_ACTION_PHRASES,
    TRIGGER_CAUSE_KEYWORDS,
)
from .patterns import TranscriptPatterns
from . import (
    CallInfo,
    Issue,
    Action,
    Resolution,
    CustomerProfile,
    TranscriptAnalysis,
    Turn,
    ResolutionState,
    RefundReference,
    TimelineEvent,
    ConversationTimeline,
    PromiseCommitment,
    MonetaryAmount,
)
from .vocabulary import TranscriptVocabulary
from clm_core.components.intent_detector import IntentDetector
from clm_core.components.target_extractor import TargetExtractor
from .utils.named_entity import EntityExtractor
from .utils.sentiment_analyzer import SentimentAnalyzer
from .utils.temporal_analyzer import TemporalAnalyzer
from ...utils.parser_rules import BaseRules
from ...utils.vocabulary import BaseVocabulary


_DEFAULT_REDACTION_PATTERN = (
    r"\[\*+REDACTED\*+\]|\*{3,}|\[REDACTED\]|<redacted>|XXX+|\[PII\]"
)


class TranscriptAnalyzer:
    def __init__(
        self,
        nlp: spacy.Language,
        vocab: BaseVocabulary,
        rules: BaseRules,
        patterns: TranscriptPatterns,
        redaction_pattern: Optional[str] = None,
    ):
        self.nlp = nlp
        self.patterns = patterns
        self.vocab = TranscriptVocabulary()
        self._redaction_pattern = redaction_pattern or _DEFAULT_REDACTION_PATTERN
        self.intent_detector = IntentDetector(nlp=nlp, vocab=vocab)
        self.target_extractor = TargetExtractor(nlp, vocab=vocab, rules=rules)
        self.temporal_extractor = TemporalAnalyzer(
            nlp=nlp,
            day_names=patterns.day_names,
            word_to_num=patterns.word_to_num,
        )
        self.sentiment_analyzer = SentimentAnalyzer(
            emotion_keywords=patterns.emotion_keywords,
        )
        self.entity_extractor = EntityExtractor(
            nlp=nlp,
            ner_domain_patterns=patterns.ner_domain_patterns,
        )

        self._issue_type_index = self._build_keyword_index(patterns.issue_type_keywords)
        self._severity_index = self._build_keyword_index(patterns.severity_keywords)
        self._resolution_index = self._build_keyword_index(patterns.resolution_keywords)
        self._billing_cause_index = self._build_keyword_index(
            patterns.billing_cause_keywords
        )
        self._technical_issue_index = self._build_keyword_index(
            patterns.technical_issue_map
        )
        self._issue_confirmation_index = self._build_keyword_index(
            patterns.issue_confirmation_map
        )
        self._troubleshooting_index = self._build_keyword_index(
            patterns.troubleshooting_actions
        )
        self._action_tokens_index = self._build_action_tokens_index()
        self._customer_intent_index = self._build_keyword_index(
            {k: set(v) for k, v in CUSTOMER_INTENT_KEYWORDS.items()}
        )
        self._explicit_agent_actions_index = self._build_keyword_index(
            {k: set(v) for k, v in EXPLICIT_AGENT_ACTION_PHRASES.items()}
        )

    @staticmethod
    def _build_keyword_index(keyword_dict: dict) -> list[tuple[str, str]]:
        """Build a flat list of (keyword, category) tuples sorted by keyword length desc.

        Longer keywords are checked first to match phrases like 'processed twice'
        before single words like 'twice'.
        """
        pairs = []
        for category, keywords in keyword_dict.items():
            for kw in keywords:
                pairs.append((kw.lower() if isinstance(kw, str) else kw, category))
        pairs.sort(key=lambda x: len(x[0]), reverse=True)
        return pairs

    @staticmethod
    def _lookup_category(text: str, index: list[tuple[str, str]]) -> Optional[str]:
        """Fast lookup using pre-built index. Returns first matching category."""
        for keyword, category in index:
            if keyword in text:
                return category
        return None

    @staticmethod
    def _lookup_all_categories(text: str, index: list[tuple[str, str]]) -> list[str]:
        """Fast lookup returning all matching categories (deduplicated, order preserved)."""
        seen = set()
        result = []
        for keyword, category in index:
            if keyword in text and category not in seen:
                seen.add(category)
                result.append(category)
        return result

    def _build_action_tokens_index(self) -> list[tuple[str, str, bool]]:
        """Build index for ACTION_TOKENS with explicit-only flag.

        Returns list of (keyword, action_event, is_explicit_only) tuples.
        Merges language-specific patterns.action_tokens with vocab defaults.
        """
        pairs = []
        # Merge: start with vocab defaults, then layer pattern overrides
        merged: dict[str, list[str]] = {}
        for raw_action, keywords in self.vocab.ACTION_TOKENS.items():
            merged[raw_action] = list(keywords)
        for raw_action, keywords in self.patterns.action_tokens.items():
            merged.setdefault(raw_action, []).extend(keywords)

        for raw_action, keywords in merged.items():
            if raw_action not in self.patterns.action_event_map:
                continue
            action_event = self.patterns.action_event_map[raw_action]
            is_explicit = action_event in self.patterns.explicit_only_actions
            for kw in keywords:
                pairs.append((kw.lower(), action_event, is_explicit))
        pairs.sort(key=lambda x: len(x[0]), reverse=True)
        return pairs

    def analyze(
        self, transcript: str, metadata: Optional[dict] = None
    ) -> TranscriptAnalysis:
        metadata = metadata or {}
        turns = self._parse_turns(transcript)

        docs = list(self.nlp.pipe([t.text for t in turns])) if turns else []
        for turn, doc in zip(turns, docs):
            turn.doc = doc
            turn.intent = self.intent_detector.get_primary_intent(
                self.intent_detector.detect(turn.text, doc=doc)
            )
            turn.targets.append(self.target_extractor.extract(text=turn.text, doc=doc))
            turn.sentiment, _ = self.sentiment_analyzer.analyze_turn(
                turn.text, turn.speaker
            )
            turn.entities = self.entity_extractor.extract(turn.text, doc=doc)

        call_info = self._extract_call_info(turns, metadata)
        customer = self._extract_customer_profile(turns)
        issues = self._extract_issues(turns, call_type=call_info.type)
        actions = self._extract_actions(turns)
        resolution = self._extract_resolution(turns)
        sentiment_trajectory = self.sentiment_analyzer.track_trajectory(turns)
        resolution_state = self._extract_resolution_state(turns, resolution)
        refund_reference = self._extract_refund_reference(turns, issues, actions)
        timeline = self._extract_conversation_timeline(turns)
        promises = self._extract_promises(turns)
        domain = self._extract_domain(call_info, issues)
        service = self._extract_service(issues, turns)
        customer_intent, secondary_intent = self._extract_customer_intent(
            issues, turns, actions
        )
        trigger_cause = self._extract_trigger_cause(turns)

        # Upgrade trigger when cancellation was deflected by a retention offer
        if trigger_cause == "REQUEST_CANCELLATION":
            action_types = {a.type for a in actions}
            if (
                "RETENTION_OFFER" in action_types
                and "SERVICE_CANCELLED" not in action_types
            ):
                trigger_cause = "CANCELLATION_DEFLECTED"

        context_provided = self._extract_context_provided(turns, call_info)
        system_actions = self._extract_system_actions(turns)
        amounts = self._extract_all_amounts(turns)
        redacted_fields = self._extract_redacted_fields(turns)

        extraction_confidence = self._compute_extraction_confidence(
            domain, customer_intent, trigger_cause, actions, resolution
        )
        requires_review = self._needs_review(
            extraction_confidence, actions, issues, call_info.type, trigger_cause
        )

        return TranscriptAnalysis(
            call_info=call_info,
            customer=customer,
            turns=turns,
            issues=issues,
            actions=actions,
            resolution=resolution,
            sentiment_trajectory=sentiment_trajectory,
            resolution_state=resolution_state,
            refund_reference=refund_reference,
            timeline=timeline,
            promises=promises,
            domain=domain,
            service=service,
            customer_intent=customer_intent,
            secondary_intent=secondary_intent,
            trigger_cause=trigger_cause,
            context_provided=context_provided,
            system_actions=system_actions,
            amounts=amounts,
            redacted_fields=redacted_fields,
            extraction_confidence=extraction_confidence,
            requires_review=requires_review,
        )

    @staticmethod
    def _compute_extraction_confidence(
        domain: Optional[str],
        customer_intent: Optional[str],
        trigger_cause: Optional[str],
        actions: list,
        resolution,
    ) -> float:
        score = 0.5
        if domain and domain != "UNCLASSIFIED":
            score += 0.1
        if customer_intent:
            score += 0.1
        if trigger_cause:
            score += 0.1
        if actions:
            score += 0.1
        if resolution.type not in ("UNKNOWN",):
            score += 0.1
        return min(score, 1.0)

    @staticmethod
    def _needs_review(
        confidence: float,
        actions: list,
        issues: list,
        call_type: str = "SUPPORT",
        trigger_cause: Optional[str] = None,
    ) -> bool:
        if confidence < 0.7:
            return True
        if not actions and not issues:
            return True
        # Trigger missing — workflow activation event could not be determined
        if not trigger_cause:
            return True
        # Sales interaction without at least one monetization action
        if call_type == "SALES":
            _MONETIZATION_ACTIONS = {
                "PLAN_UPGRADED",
                "ADDON_OFFERED",
                "ADDON_ACTIVATED",
                "DISCOUNT_OFFERED",
                "DISCOUNT_APPLIED",
                "TRIAL_ACTIVATED",
            }
            if not ({a.type for a in actions} & _MONETIZATION_ACTIONS):
                return True
        return False

    def _parse_turns(self, transcript: str) -> list[Turn]:
        agent_labels = self.patterns.agent_speaker_labels or ["agent", "agente"]
        customer_labels = self.patterns.customer_speaker_labels or [
            "customer",
            "caller",
            "cliente",
            "client",
        ]
        turns = []
        for line in transcript.strip().split("\n"):
            if not line or ":" not in line:
                continue
            speaker, text = line.split(":", 1)
            speaker = speaker.strip().lower()
            if any(label in speaker for label in agent_labels):
                speaker = "agent"
            elif any(label in speaker for label in customer_labels):
                speaker = "customer"
            else:
                speaker = "system"
            turns.append(Turn(speaker=speaker, text=text.strip()))
        return turns

    def _extract_actions(self, turns: list[Turn]) -> list[Action]:
        """
        Extract canonical, atomic ACTION EVENTS from agent turns.

        Each Action represents a confirmed fact (event),
        not a derived outcome or result.
        """
        actions: dict[str, Action] = {}

        for turn in turns:
            if turn.speaker != "agent":
                continue

            text = turn.text

            action_events = self._detect_action_events(text)
            if not action_events:
                continue

            for action_type in action_events:
                if action_type not in actions:
                    actions[action_type] = Action(type=action_type, attributes={})

                action = actions[action_type]

                if "REFUND" in action_type or "ESCALATION" in action_type:
                    if ref := self._extract_reference_number(turn):
                        action.attributes["reference"] = ref

                if "REFUND" in action_type or "CREDIT" in action_type:
                    amount, method = self._extract_financial_details(turn)
                    if amount:
                        action.attributes.setdefault("amount", amount)
                    if method:
                        action.attributes.setdefault("payment_method", method)

        return list(actions.values())

    def _detect_action_events(self, text: str) -> list[str]:
        text_lower = text.lower()
        seen = set()
        events = []

        # 1. Check explicit agent action phrases first (highest priority)
        for kw, category in self._explicit_agent_actions_index:
            if kw in text_lower and category not in seen:
                seen.add(category)
                events.append(category)

        # 2. Issue confirmation patterns
        for kw, category in self._issue_confirmation_index:
            if kw in text_lower and category not in seen:
                seen.add(category)
                events.append(category)

        # 3. Troubleshooting patterns
        for kw, category in self._troubleshooting_index:
            if kw in text_lower and category not in seen:
                seen.add(category)
                events.append(category)

        # 4. Action tokens (keyword-based)
        for kw, action_event, is_explicit in self._action_tokens_index:
            if action_event in seen:
                continue
            if kw not in text_lower:
                continue
            if is_explicit:
                phrases = self.patterns.explicit_action_phrases.get(action_event, set())
                if not any(p in text_lower for p in phrases):
                    continue
            seen.add(action_event)
            events.append(action_event)

        return events

    def _detect_technical_issue_detail(self, text: str) -> Optional[str]:
        return self._lookup_category(text.lower(), self._technical_issue_index)

    def _extract_action_details(self, action_type: str, turn: Turn):
        amount, method = None, None
        attributes = {}
        if action_type in ["REFUND", "CREDIT", "CHARGE", "PAYMENT"]:
            amount, method = self._extract_financial_details(turn)
            if ref := self._extract_reference_number(turn):
                attributes["reference"] = ref
            if timeline := self._extract_timeline(turn.text):
                attributes["timeline"] = timeline
        return amount, method, attributes

    def _extract_resolution(self, turns: list[Turn]) -> Resolution:
        agent_turns = [t for t in turns if t.speaker == "agent"]
        recent = agent_turns[-5:] if agent_turns else []

        for turn in reversed(recent):
            text = turn.text.lower()
            key = self._lookup_category(text, self._resolution_index)
            if key:
                if key == "PENDING_REPLACEMENT":
                    res_type = "PENDING"
                    next_steps = "REPLACEMENT"
                else:
                    res_type = key
                    next_steps = None
                timeline = self._extract_timeline(text)
                return Resolution(
                    type=res_type, timeline=timeline, next_steps=next_steps
                )

        return Resolution(type="UNKNOWN", timeline=None, next_steps=None)

    @staticmethod
    def _match_any(text: str, keywords: list[str]) -> bool:
        return any(kw in text for kw in keywords)

    def _extract_timeline(self, text: str) -> Optional[str]:
        text_lower = text.lower()

        # Language-specific timeline patterns first (more specific, e.g. "3 a 5 días hábiles")
        for regex, fmt in self.patterns.timeline_patterns:
            if match := re.search(regex, text_lower, re.I):
                groups = match.groups()
                return fmt.format(*groups)

        # Temporal extractor (duration inference)
        pattern = self.temporal_extractor.extract(text)
        if pattern:
            if getattr(pattern, "resolved_date", None):
                return pattern.resolved_date
            if getattr(pattern, "duration", None):
                return str(pattern.duration).upper()

        # Language-specific timeline keywords (includes "today"/"tomorrow" equivalents)
        timeline_kw = self.patterns.timeline_keywords or {}
        for kw, value in timeline_kw.items():
            if kw in text_lower:
                return value

        # "in X hours/days" patterns with word number support (language-specific word_to_num)
        word_to_num = self.patterns.word_to_num or {}
        word_alts = "|".join(re.escape(w) for w in word_to_num) if word_to_num else None
        num_pat = rf"(?:{word_alts}|\d+)" if word_alts else r"\d+"
        if match := re.search(
            rf"in\s+(a\s+)?({num_pat})\s+(?:of\s+)?hours?", text_lower, re.I
        ):
            raw = ((match.group(1) or "") + match.group(2)).strip().lower()
            num = word_to_num.get(raw, match.group(2))
            return f"{num}h"
        if match := re.search(
            rf"in\s+(a\s+)?({num_pat})\s+(?:of\s+)?days?", text_lower, re.I
        ):
            raw = ((match.group(1) or "") + match.group(2)).strip().lower()
            num = word_to_num.get(raw, match.group(2))
            return f"{num}d"
        if match := re.search(
            rf"by\s+(a\s+)?({num_pat})\s+(?:of\s+)?days?", text_lower, re.I
        ):
            raw = ((match.group(1) or "") + match.group(2)).strip().lower()
            num = word_to_num.get(raw, match.group(2))
            return f"{num}d"
        return None

    def _determine_action_result(
        self, turns: list[Turn], action_index: int, action_turn: Turn
    ) -> str:
        text_lower = action_turn.text.lower()

        if any(
            k in text_lower
            for k in self.patterns.action_completion_keywords
            | self.patterns.action_completion_phrases
        ):
            return "COMPLETED"

        for pattern in self.patterns.action_now_patterns:
            if re.search(pattern, text_lower):
                return "COMPLETED"

        for t in turns[action_index + 1 : action_index + 3]:
            tl = t.text.lower()
            if t.speaker == "customer" and any(
                k in tl for k in self.patterns.positive_customer_confirmations
            ):
                return "COMPLETED"
            if t.speaker == "agent" and any(
                k in tl for k in self.patterns.agent_confirmation_phrases
            ):
                return "COMPLETED"
        return "PENDING"

    def _extract_financial_details(
        self, turn: Turn
    ) -> tuple[Optional[str], Optional[str]]:
        """
        Prefer using named entities from turn.entities if present; otherwise fallback to regex heuristics.
        Returns (amount, payment_method)
        """
        amount = None
        ents = getattr(turn, "entities", {}) or {}

        money_candidates = ents.get("money") or ents.get("money_amounts") or []
        if money_candidates:
            amount = money_candidates[0]

        if not amount:
            if m := re.search(r"\$\s?([\d,]+(?:\.\d{1,2})?)", turn.text):
                amount = f"${m.group(1)}"

        method = self._detect_refund_method(turn.text.lower())
        return amount, method

    # Common words that should never be extracted as reference numbers
    _REF_BLACKLIST = {
        "number",
        "reference",
        "confirmation",
        "immediately",
        "right",
        "today",
        "tomorrow",
        "please",
        "thank",
        "thanks",
        "okay",
        "here",
        "that",
        "this",
        "your",
        "the",
        "will",
        "been",
    }

    @classmethod
    def _extract_reference_number(cls, turn: Turn) -> Optional[str]:
        """
        Extracts reference numbers like:
         - RFD-908712
         - ESC-45390
         - "reference number is RFD-..." or "confirmation #12345"
        """
        text = turn.text
        # Structured PREFIX-DIGITS pattern (most reliable)
        if m := re.search(r"\b([A-Z]{2,5}-\d{3,})\b", text):
            return m.group(0)

        # "reference/confirmation" followed by a code (require at least one digit)
        if m := re.search(
            r"(?:reference|confirmation|ref)[^\w]{0,6}#?\s*([A-Z0-9-]*\d[A-Z0-9-]{2,29})",
            text,
            re.I,
        ):
            candidate = m.group(1)
            if candidate.lower() not in cls._REF_BLACKLIST:
                return candidate

        # "id/ticket/case/order" followed by a code (require at least one digit)
        if m := re.search(
            r"(?:id|ticket|case|order)[^\w]{0,6}#?\s*([A-Z0-9-]*\d[A-Z0-9-]{2,29})",
            text,
            re.I,
        ):
            candidate = m.group(1)
            if candidate.lower() not in cls._REF_BLACKLIST:
                return candidate

        return None

    def _extract_customer_profile(self, turns: list[Turn]) -> CustomerProfile:
        """Extract the customer's profile from the conversation.

        Args:
            turns: List of turns in the conversation.

        Returns:
            The customer's profile if found, otherwise None.
        Examples:
            >>> analyzer = TranscriptAnalyzer()
            >>> turns = [
            ...     Turn(speaker="customer", text="I have a problem with my account."),
            ...     Turn(speaker="agent", text="What seems to be the issue?"),
            ...     Turn(speaker="customer", text="I'm not getting my bill."),
            ... ]
            >>> analyzer._extract_customer_profile(turns)
            CustomerProfile(name='John', account='12345', tier='STANDARD')
        """
        profile = CustomerProfile()
        profile.name = self._extract_customer_name(turns)

        for t in turns:
            ents = getattr(t, "entities", {}) or {}
            if emails := ents.get("emails"):
                profile.attributes = profile.attributes or {}
                profile.attributes["email"] = emails[0]
            if accounts := ents.get("accounts") or ents.get("account_numbers"):
                profile.account = profile.account or accounts[0]
            if plans := ents.get("plans"):
                profile.tier = profile.tier or self._map_plan_to_tier(plans[0])

        return profile

    @staticmethod
    def _map_plan_to_tier(plan: str) -> str:
        """Map a plan to a tier.

        Args:
            plan: The plan name.

        Returns:
            The tier corresponding to the plan.
        Examples:
            >>> analyzer = TranscriptAnalyzer()
            >>> analyzer._map_plan_to_tier("Premium")
            'PREMIUM'
        """
        plan = plan.lower()
        if "premium" in plan:
            return "PREMIUM"
        if "enterprise" in plan:
            return "ENTERPRISE"
        if "basic" in plan:
            return "BASIC"
        return "STANDARD"

    def _extract_customer_name(self, turns: list[Turn]) -> Optional[str]:
        """Extract the customer's name from the conversation.

        Args:
            turns: List of turns in the conversation.

        Returns:
            The customer's name if found, otherwise None.
        Examples:
            >>> analyzer = TranscriptAnalyzer()
            >>> turns = [
            ...     Turn(speaker="customer", text="I have a problem with my account."),
            ...     Turn(speaker="agent", text="What seems to be the issue?"),
            ...     Turn(speaker="customer", text="I'm not getting my bill."),
            ... ]
            >>> analyzer._extract_customer_name(turns)
            'John'
        """
        intro_patterns = self.patterns.name_intro_patterns or [
            r"(?:my name is|i'?m|this is)\s+([A-Z][a-z]+)"
        ]
        thanks_patterns = self.patterns.name_thanks_patterns or [
            r"thank(?:s| you),\s+([A-Z][a-z]+)"
        ]
        for t in turns[:3]:
            if t.speaker == "agent":
                doc = t.doc
                if doc:
                    for ent in doc.ents:
                        if ent.label_ == "PERSON":
                            return ent.text
                for pat in intro_patterns:
                    if match := re.search(pat, t.text, re.I):
                        return match.group(1).title()
                for pat in thanks_patterns:
                    if match := re.search(pat, t.text):
                        return match.group(1)
        for t in turns:
            ents = getattr(t, "entities", {}) or {}
            emails = ents.get("emails") or []
            if emails:
                local_part = emails[0].split("@")[0]
                if "." in local_part:
                    return local_part.split(".")[0].title()
        return None

    def _extract_issues(
        self, turns: list[Turn], call_type: str = "SUPPORT"
    ) -> list[Issue]:
        """Extract issues from a list of turns.

        Args:
            turns: List of turns in the conversation.

        Returns:
            A list of issues extracted from the conversation.
        Examples:
            >>> analyzer = TranscriptAnalyzer()
            >>> turns = [
            ...     Turn(speaker="customer", text="I have a problem with my account."),
            ...     Turn(speaker="agent", text="What seems to be the issue?"),
            ...     Turn(speaker="customer", text="I'm not getting my bill."),
            ... ]
            >>> analyzer._extract_issues(turns)
            [Issue(type="ACCOUNT_ISSUE", severity="LOW", cause="BILLING_DISPUTE", plan_change=None, amounts=[], days=[])]
        """
        customer_text = " ".join(
            t.text for t in turns if t.speaker == "customer"
        ).lower()
        issue_type = self._get_issue_type(customer_text)

        # Fallback: if no issue found from customer turns, try agent turns
        if not issue_type:
            agent_text = " ".join(t.text for t in turns if t.speaker == "agent").lower()
            issue_type = self._get_issue_type(agent_text)

        if not issue_type:
            return []

        # Monetary context guardrail: in a SALES context, amounts are pricing information,
        # not billing disputes. Billing dispute requires unexpected charge language + refund
        # context — money alone is not a billing issue.
        _BILLING_ISSUE_TYPES = {"BILLING_DISPUTE", "UNEXPECTED_CHARGE"}
        if call_type == "SALES" and issue_type in _BILLING_ISSUE_TYPES:
            issue_type = None

        if not issue_type:
            return []

        severity = self._detect_severity(customer_text)
        cause, plan_change = None, None
        amounts = []

        if issue_type in ["BILLING_DISPUTE", "UNEXPECTED_CHARGE", "REFUND_REQUEST"]:
            cause, plan_change = self._detect_billing_cause(turns)
            amounts = self._extract_disputed_amounts(turns)

        days = self.temporal_extractor.extract(customer_text).days or []
        attrs = {"days": days} if days else {}
        if issue_type in {"CONNECTIVITY", "TECHNICAL"}:
            cause = self._detect_technical_issue_detail(customer_text)
            return [
                Issue(
                    type=issue_type,
                    severity=severity,
                    cause=cause,
                    attributes=attrs,
                )
            ]

        return [
            Issue(
                type=issue_type,
                severity=severity,
                cause=cause,
                plan_change=plan_change,
                disputed_amounts=amounts,
                attributes=attrs,
            )
        ]

    def _get_issue_type(self, text: str) -> Optional[str]:
        return self._lookup_category(text, self._issue_type_index)

    def _detect_severity(self, text: str) -> str:
        return self._lookup_category(text.lower(), self._severity_index) or "LOW"

    def _extract_disputed_amounts(self, turns: list[Turn]) -> list[str]:
        """Extract disputed amounts from customer turns.

        Args:
            turns: List of turns.

        Returns:
            List of disputed amounts.
        Examples:
            >>> _extract_disputed_amounts([Turn("customer", "I think my bill is wrong"), Turn("agent", "What amount do you think is wrong?"), Turn("customer", "I think it's $100")])
            ['$100']
        """
        keywords = list(self.patterns.disputed_amount_keywords or [])
        amounts = []
        for t in (t for t in turns if t.speaker == "customer"):
            if any(k in t.text.lower() for k in keywords):
                amounts.extend(getattr(t, "entities", {}).get("money", []))
        return list(dict.fromkeys(amounts))

    _AMOUNT_REGEX = re.compile(
        r"\$\s?[\d,]+(?:\.\d{1,2})?|\b[\d,]+(?:\.\d{1,2})?\s*(?:USD|EUR)\b",
        re.I,
    )

    def _extract_all_amounts(self, turns: list[Turn]) -> list[MonetaryAmount]:
        """Extract all monetary amounts from all turns with their reason."""
        amount_reason_map = self.patterns.amount_reason_context or []
        billing_period_map = self.patterns.billing_period_context or []
        seen: set[tuple[str, Optional[str], str]] = set()
        results: list[MonetaryAmount] = []

        for idx, turn in enumerate(turns):
            text = turn.text
            for match in self._AMOUNT_REGEX.finditer(text):
                amount_str = match.group(0).strip()
                start, end = match.start(), match.end()
                ctx_start = max(0, start - 50)
                ctx_end = min(len(text), end + 50)
                context = text[ctx_start:ctx_end].lower()

                reason = None
                for keyword, r in amount_reason_map:
                    if keyword in context:
                        reason = r
                        break

                # Secondary pass: narrow 30-char suffix window for billing period
                # indicators (MONTH/YEAR).  A wide context window causes adjacent
                # prices ("$49 a month, or $480 annual") to share keywords; the
                # suffix-only window resolves the ambiguity.
                if not reason and billing_period_map:
                    suffix = text[end : min(len(text), end + 30)].lower()
                    for keyword, r in billing_period_map:
                        if keyword in suffix:
                            reason = r
                            break

                if not reason:
                    continue  # skip bare uncontextualized amounts

                key = (amount_str, reason, turn.speaker)
                if key not in seen:
                    seen.add(key)
                    results.append(
                        MonetaryAmount(
                            amount=amount_str,
                            reason=reason,
                            speaker=turn.speaker,
                            turn_index=idx,
                        )
                    )

        return results

    def _extract_redacted_fields(self, turns: list[Turn]) -> list[str]:
        """Detect redacted field tokens from all turns using configured redaction_pattern."""
        redacted_field_context = self.patterns.redacted_field_context or []
        pattern = re.compile(self._redaction_pattern, re.I)
        seen: set[str] = set()
        results: list[str] = []

        for turn in turns:
            text = turn.text
            for match in pattern.finditer(text):
                start, end = match.start(), match.end()
                ctx_start = max(0, start - 40)
                ctx_end = min(len(text), end + 40)
                context = text[ctx_start:ctx_end].lower()

                token = "FIELD_REDACTED"
                for keyword, field_token in redacted_field_context:
                    if keyword in context:
                        token = field_token
                        break

                if token not in seen:
                    seen.add(token)
                    results.append(token)

        return results

    def _detect_billing_cause(
        self, turns: list[Turn]
    ) -> tuple[Optional[str], Optional[str]]:
        for t in (t for t in turns if t.speaker == "agent"):
            text = t.text.lower()
            cause = self._lookup_category(text, self._billing_cause_index)
            if cause:
                plan_change = None
                if cause in {"MID_CYCLE_UPGRADE", "MID_CYCLE_DOWNGRADE"}:
                    if match := re.search(r"from (\w+) to (\w+)", text):
                        plan_change = (
                            f"{match.group(1).upper()}→{match.group(2).upper()}"
                        )
                return cause, plan_change
        return None, None

    def _extract_call_info(self, turns: list[Turn], metadata: dict) -> CallInfo:
        """
        Extracts call information from the thread_encoder.

        Args:
            turns: List of turns in the thread_encoder.
            metadata: Metadata associated with the call.

        Returns:
            CallInfo object containing extracted information.
        """
        agent_name = metadata.get("agent") or self._detect_agent_name(turns)
        full_text = " ".join(t.text.lower() for t in turns)
        sales_keywords = self.patterns.call_type_sales_keywords or [
            "upgrade",
            "pricing",
            "buy",
            "interested in",
        ]
        call_type = (
            "SALES" if any(x in full_text for x in sales_keywords) else "SUPPORT"
        )
        return CallInfo(
            call_id=metadata.get("call_id", "unknown"),
            type=call_type,
            channel=metadata.get("channel", "VOICE"),
            duration=len(turns),
            agent=agent_name,
        )

    # Common words that should not be extracted as agent names
    _NAME_BLACKLIST = {
        "sorry",
        "happy",
        "glad",
        "pleased",
        "here",
        "calling",
        "able",
        "going",
        "looking",
        "checking",
        "helping",
        "available",
        "ready",
        "sure",
        "certain",
        "afraid",
        "delighted",
        "excited",
        "thrilled",
    }

    def _detect_agent_name(self, turns: list[Turn]) -> Optional[str]:
        """Detects the agent's name from the thread_encoder.
        We will find the agent's name by looking for a PERSON entity
        in the text or by matching a pattern.

        Args:
            turns: List of turns in the thread_encoder.

        Returns:
            The detected agent's name or None if not found.

        Examples:
            >>> _detect_agent_name([Turn("agent", "Hello, my name is John.")])
            'John'
        """
        agent_patterns = self.patterns.agent_name_patterns or [
            r"(?:my name is|this is)\s+([A-Z][a-z]+)"
        ]
        for t in (t for t in turns[:3] if t.speaker == "agent"):
            doc = getattr(t, "doc", None)
            if doc:
                for ent in doc.ents:
                    if ent.label_ == "PERSON":
                        name = ent.text.lower()
                        if name not in self._NAME_BLACKLIST:
                            return ent.text
            for pat in agent_patterns:
                if match := re.search(pat, t.text, re.I):
                    candidate = match.group(1)
                    if candidate.lower() not in self._NAME_BLACKLIST:
                        return candidate
        return None

    def _extract_resolution_state(
        self, turns: list[Turn], resolution: Resolution
    ) -> ResolutionState:
        """Extract enhanced resolution state with granularity."""
        agent_turns = [t for t in turns if t.speaker == "agent"]
        customer_turns = [t for t in turns if t.speaker == "customer"]

        completeness = self._detect_resolution_completeness(agent_turns)
        customer_satisfaction = self._derive_customer_satisfaction(customer_turns)
        follow_up_needed, follow_up_reason = self._detect_follow_up_needed(agent_turns)
        resolution_type = self._map_resolution_to_state(
            resolution.type, completeness, follow_up_needed, customer_satisfaction
        )

        return ResolutionState(
            type=resolution_type,
            completeness=completeness,
            customer_satisfaction=customer_satisfaction,
            follow_up_needed=follow_up_needed,
            follow_up_reason=follow_up_reason,
        )

    def _detect_resolution_completeness(self, agent_turns: list[Turn]) -> Optional[str]:
        """Detect if resolution was full, partial, or none."""
        recent = agent_turns[-5:] if agent_turns else []
        text = " ".join(t.text.lower() for t in recent)

        tokens = (
            self.patterns.resolution_state_tokens or self.vocab.RESOLUTION_STATE_TOKENS
        )
        for state, keywords in tokens.items():
            if any(kw in text for kw in keywords):
                if state == "FULLY_RESOLVED":
                    return "FULL"
                elif state == "PARTIALLY_RESOLVED":
                    return "PARTIAL"
        return None

    def _derive_customer_satisfaction(
        self, customer_turns: list[Turn]
    ) -> Optional[str]:
        """Derive satisfaction from final customer turns."""
        if not customer_turns:
            return None

        final_turns = customer_turns[-3:]
        text = " ".join(t.text.lower() for t in final_turns)

        tokens = (
            self.patterns.customer_satisfaction_tokens
            or self.vocab.CUSTOMER_SATISFACTION_TOKENS
        )
        for satisfaction, keywords in tokens.items():
            if any(kw in text for kw in keywords):
                return satisfaction

        if final_turns:
            final_sentiment = final_turns[-1].sentiment
            if final_sentiment in ["SATISFIED", "GRATEFUL", "RELIEVED"]:
                return "SATISFIED"
            elif final_sentiment in ["FRUSTRATED", "ANGRY", "DISAPPOINTED"]:
                return "DISSATISFIED"

        return "NEUTRAL"

    def _detect_follow_up_needed(
        self, agent_turns: list[Turn]
    ) -> tuple[bool, Optional[str]]:
        """Detect if follow-up is needed and why."""
        recent = agent_turns[-3:] if agent_turns else []
        text = " ".join(t.text.lower() for t in recent)

        tokens = (
            self.patterns.follow_up_needed_tokens or self.vocab.FOLLOW_UP_NEEDED_TOKENS
        )
        for reason, keywords in tokens.items():
            if any(kw in text for kw in keywords):
                return True, reason

        return False, None

    @staticmethod
    def _map_resolution_to_state(
        resolution_type: str,
        completeness: Optional[str],
        follow_up_needed: bool,
        customer_satisfaction: Optional[str] = None,
    ) -> str:
        """Map resolution to granular state, considering customer satisfaction."""
        if resolution_type == "RESOLVED":
            if completeness == "FULL" and not follow_up_needed:
                return "FULLY_RESOLVED"
            elif completeness == "PARTIAL":
                return "PARTIALLY_RESOLVED"
            else:
                return "RESOLVED_PENDING_VERIFICATION"
        elif resolution_type == "PENDING":
            return "PENDING"
        elif resolution_type == "ESCALATED":
            return "ESCALATED"
        else:
            # If resolution is UNKNOWN but customer is satisfied, infer resolution
            if customer_satisfaction == "SATISFIED":
                if completeness == "FULL":
                    return "FULLY_RESOLVED"
                return "RESOLVED"
            elif customer_satisfaction == "NEUTRAL":
                return "RESOLVED_PENDING_VERIFICATION"
            return "UNRESOLVED"

    def _extract_refund_reference(
        self, turns: list[Turn], issues: list[Issue], actions: list[Action]
    ) -> Optional[RefundReference]:
        """Extract refund details for billing/refund cases (case-dependent)."""
        issue_types = [i.type for i in issues]
        is_refund_case = any(
            it in issue_types
            for it in [
                "BILLING_DISPUTE",
                "REFUND_REQUEST",
                "DUPLICATE_CHARGE",
                "UNEXPECTED_CHARGE",
                "MISSING_REFUND",
            ]
        )

        has_refund_action = any(
            "REFUND" in a.type or "CREDIT" in a.type for a in actions
        )

        if not is_refund_case and not has_refund_action:
            return None

        refund = RefundReference()

        for turn in turns:
            if turn.speaker != "agent":
                continue

            text = turn.text
            text_lower = text.lower()

            if not refund.reference_number:
                refund.reference_number = self._extract_refund_reference_number(text)

            if not refund.amount:
                amount, _ = self._extract_financial_details(turn)
                refund.amount = amount

            if not refund.method:
                refund.method = self._detect_refund_method(text_lower)

            if not refund.status:
                refund.status = self._detect_refund_status(text_lower)

            if not refund.timeline:
                refund.timeline = self._extract_timeline(text_lower)

        # Also pull from action attributes
        for action in actions:
            if "REFUND" in action.type or "CREDIT" in action.type:
                if not refund.reference_number and "reference" in action.attributes:
                    refund.reference_number = action.attributes["reference"]
                if not refund.amount and action.amount:
                    refund.amount = action.amount
                if not refund.method and action.payment_method:
                    refund.method = action.payment_method

        # Only return if we have meaningful data
        if any([refund.reference_number, refund.amount, refund.status]):
            return refund

        return None

    # Prefixes that identify non-refund reference IDs (escalation, ticket, incident, etc.)
    _NON_REFUND_PREFIXES = frozenset(
        {
            "ESC",
            "TKT",
            "INC",
            "CAS",
            "TEC",
            "SUP",
            "SRQ",
            "PRB",
            "CHG",
        }
    )

    @classmethod
    def _extract_refund_reference_number(cls, text: str) -> Optional[str]:
        """Extract refund-specific reference numbers.

        Prefers known refund prefixes (RFD, REF, CRD, BCR) and explicitly
        excludes known non-refund prefixes (ESC, TKT, INC, TEC, ...) from the
        generic PREFIX-DIGITS fallback pattern.
        """
        # 1. Known refund-specific prefixes (most reliable)
        for pattern in (
            r"\bRFD-?\d{5,10}\b",
            r"\bREF-?\d{5,10}\b",
            r"\bCRD-?\d{5,10}\b",
            r"\bBCR-?\d{5,10}\b",
        ):
            if match := re.search(pattern, text, re.I):
                return match.group(0)

        # 2. "refund reference/number/id" followed by an alphanumeric code
        if match := re.search(
            r"refund\s*(?:reference\s*)?(?:number|id|#)?\s*[:=—–-]?\s*([A-Z0-9-]*\d[A-Z0-9-]{3,14})",
            text,
            re.I,
        ):
            return match.group(1)

        # 3. Generic PREFIX-DIGITS pattern — only if prefix is not a known
        #    non-refund identifier type (e.g. ESC-, TKT-, INC-).
        if match := re.search(r"\b([A-Z]{2,5})-(\d{3,})\b", text):
            prefix = match.group(1).upper()
            if prefix not in cls._NON_REFUND_PREFIXES:
                return match.group(0)

        return None

    def _detect_refund_method(self, text: str) -> Optional[str]:
        """Detect refund method from text."""
        tokens = self.patterns.refund_method_tokens or self.vocab.REFUND_METHOD_TOKENS
        for method, keywords in tokens.items():
            if any(kw in text for kw in keywords):
                return method
        return None

    def _detect_refund_status(self, text: str) -> Optional[str]:
        """Detect refund status from text."""
        tokens = self.patterns.refund_status_tokens or self.vocab.REFUND_STATUS_TOKENS
        for status, keywords in tokens.items():
            if any(kw in text for kw in keywords):
                return status
        return None

    def _extract_conversation_timeline(self, turns: list[Turn]) -> ConversationTimeline:
        """Extract conversation timeline with key events."""
        events = []
        first_issue_turn = None
        first_resolution_turn = None

        for idx, turn in enumerate(turns):
            text_lower = turn.text.lower()

            event_type = self._detect_timeline_event_type(text_lower, turn.speaker)

            if event_type:
                event = TimelineEvent(
                    event_type=event_type,
                    description=self._summarize_event(turn.text, event_type),
                    turn_index=idx,
                    timestamp=turn.timestamp,
                    actor=turn.speaker,
                )
                events.append(event)

                if event_type == "ISSUE_RAISED" and first_issue_turn is None:
                    first_issue_turn = idx
                elif (
                    event_type in ["RESOLUTION_PROPOSED", "ACTION_TAKEN"]
                    and first_resolution_turn is None
                ):
                    first_resolution_turn = idx

        time_to_first_action = None
        time_to_resolution = None

        if first_issue_turn is not None:
            first_action = next(
                (e.turn_index for e in events if e.event_type == "ACTION_TAKEN"), None
            )
            if first_action is not None:
                time_to_first_action = first_action - first_issue_turn

            if first_resolution_turn is not None:
                time_to_resolution = first_resolution_turn - first_issue_turn

        return ConversationTimeline(
            events=events,
            first_issue_turn=first_issue_turn,
            first_resolution_turn=first_resolution_turn,
            time_to_first_action=time_to_first_action,
            time_to_resolution=time_to_resolution,
        )

    def _detect_timeline_event_type(self, text: str, speaker: str) -> Optional[str]:
        """Detect timeline event type from turn text."""
        tokens = self.patterns.timeline_event_tokens or self.vocab.TIMELINE_EVENT_TOKENS
        for event_type, keywords in tokens.items():
            # Issue raised typically by customer
            if event_type == "ISSUE_RAISED" and speaker != "customer":
                continue
            # Most other events by agent
            if (
                event_type
                in ["ACTION_TAKEN", "RESOLUTION_PROPOSED", "INVESTIGATION_STARTED"]
                and speaker != "agent"
            ):
                continue

            if any(kw in text for kw in keywords):
                return event_type

        return None

    @staticmethod
    def _summarize_event(text: str, event_type: str) -> str:
        """Create brief summary of event."""
        sentences = text.split(".")
        summary = sentences[0] if sentences else text
        return summary[:100].strip()

    def _extract_promises(self, turns: list[Turn]) -> list[PromiseCommitment]:
        """Extract agent promises and commitments (case-dependent)."""
        promises = []

        # Additional commitment patterns checked separately (language-specific)
        extra_commitment_patterns = self.patterns.extra_commitment_patterns or {}

        for idx, turn in enumerate(turns):
            if turn.speaker != "agent":
                continue

            text = turn.text
            text_lower = text.lower()

            tokens = (
                self.patterns.promise_commitment_tokens
                or self.vocab.PROMISE_COMMITMENT_TOKENS
            )
            for promise_type, keywords in tokens.items():
                matching_keyword = next(
                    (kw for kw in keywords if kw in text_lower), None
                )

                if matching_keyword:
                    timeline = self._extract_promise_timeline(text_lower)
                    amount = None

                    if promise_type in ["CREDIT_PROMISE", "REFUND_PROMISE"]:
                        amount, _ = self._extract_financial_details(turn)

                    confidence = self._calculate_promise_confidence(
                        text_lower, promise_type
                    )

                    promise = PromiseCommitment(
                        type=promise_type,
                        description=self._extract_promise_description(
                            text, matching_keyword
                        ),
                        timeline=timeline,
                        amount=amount,
                        turn_index=idx,
                        confidence=confidence,
                    )
                    promises.append(promise)

            # Check extra commitment patterns
            for commit_type, phrases in extra_commitment_patterns.items():
                matching = next((p for p in phrases if p in text_lower), None)
                if matching:
                    timeline = self._extract_promise_timeline(text_lower)
                    promises.append(
                        PromiseCommitment(
                            type=commit_type,
                            description=self._extract_promise_description(
                                text, matching
                            ),
                            timeline=timeline,
                            turn_index=idx,
                            confidence=0.8,
                        )
                    )

        return self._dedupe_promises(promises)

    def _extract_promise_timeline(self, text: str) -> Optional[str]:
        """Extract timeline from promise text."""
        timeline = self._extract_timeline(text)
        if timeline:
            return timeline

        # Language-specific promise timeline patterns (format-string based)
        for regex, fmt in self.patterns.promise_timeline_patterns:
            if match := re.search(regex, text, re.I):
                groups = match.groups()
                formatted = fmt.format(*[g.upper() if g else g for g in groups])
                return formatted

        return None

    def _calculate_promise_confidence(self, text: str, promise_type: str) -> float:
        """Calculate confidence in promise detection."""
        confidence = 0.6

        strong_indicators = list(self.patterns.promise_confidence_strong or [])
        if any(ind in text for ind in strong_indicators):
            confidence += 0.2

        timeline_re = r"\d+ (?:day|hour)"
        if self.patterns.timeline_keywords:
            kw_alts = "|".join(re.escape(k) for k in self.patterns.timeline_keywords)
            timeline_re = rf"{timeline_re}|{kw_alts}"
        if re.search(timeline_re, text, re.I):
            confidence += 0.1

        if promise_type in ["CREDIT_PROMISE", "REFUND_PROMISE"]:
            if "$" in text or re.search(r"\d+\.?\d*", text):
                confidence += 0.1

        return min(confidence, 1.0)

    @staticmethod
    def _extract_promise_description(text: str, keyword: str) -> str:
        """Extract the promise description around the keyword."""
        sentences = text.split(".")
        for sentence in sentences:
            if keyword in sentence.lower():
                return sentence.strip()[:150]
        return text[:150]

    # Promise types where only the first detected instance is kept (no timeline variants).
    # These promises describe a single outcome whose timeline is set on first mention;
    # a later agent turn referencing the same promise with a different timeline
    # (e.g. "later today" vs "3-5 business days") would otherwise emit a conflicting token.
    _SINGLE_INSTANCE_PROMISE_TYPES = {"REFUND_PROMISE", "CREDIT_PROMISE"}

    @staticmethod
    def _dedupe_promises(promises: list[PromiseCommitment]) -> list[PromiseCommitment]:
        """Remove duplicate promises of the same type.

        For REFUND_PROMISE and CREDIT_PROMISE, only one instance per type is kept.
        When multiple instances exist, the one with an amount is preferred (more
        informative), since the same promise may be detected by both the primary
        token loop (with amount extraction) and the extra patterns loop (without).
        This prevents semantic duplicates like CREDIT_24H_20 and CREDIT_24H.
        """
        seen_keys: set = set()
        unique = []

        # For single-instance types, pick the best (amount-bearing) representative first
        single_best: dict = {}
        for p in promises:
            if p.type in TranscriptAnalyzer._SINGLE_INSTANCE_PROMISE_TYPES:
                existing = single_best.get(p.type)
                if existing is None or (p.amount and not existing.amount):
                    single_best[p.type] = p

        emitted_single: set = set()
        for p in promises:
            if p.type in TranscriptAnalyzer._SINGLE_INSTANCE_PROMISE_TYPES:
                if p.type not in emitted_single and p is single_best[p.type]:
                    emitted_single.add(p.type)
                    unique.append(p)
            else:
                key = (p.type, p.amount, p.timeline)
                if key not in seen_keys:
                    seen_keys.add(key)
                    unique.append(p)
        return unique

    @classmethod
    def _extract_domain(cls, call_info: CallInfo, issues: list[Issue]) -> Optional[str]:
        """Extract v2 DOMAIN from issues and call info.

        Issue-derived domain takes priority, except when the call type provides
        stronger signal (e.g. a SALES call should not resolve to FULFILLMENT).
        Falls back to call_info.type when no issue maps to a domain.
        """
        if issues:
            issue_type = issues[0].type
            if issue_type in ISSUE_TO_DOMAIN:
                domain = ISSUE_TO_DOMAIN[issue_type]
                # A SALES call should not resolve to FULFILLMENT — the shipment
                # keywords likely matched incidentally (e.g. "analytics tracking").
                # Try remaining issues for a better match first, then fall back to
                # the call-type-derived domain.
                if call_info.type == "SALES" and domain == "FULFILLMENT":
                    for issue in issues[1:]:
                        alt = ISSUE_TO_DOMAIN.get(issue.type)
                        if alt and alt != "FULFILLMENT":
                            return alt
                    return CALL_TYPE_TO_DOMAIN.get(call_info.type, "PRODUCT")
                return domain

        # Fallback: derive domain from the detected call type, but only for
        # call types that carry specific domain signal (not the generic SUPPORT type).
        _INFORMATIVE_CALL_TYPES = {
            "BILLING",
            "TECHNICAL",
            "SALES",
            "RETENTION",
            "LOGISTICS",
            "RETURNS",
        }
        if (
            call_info.type in _INFORMATIVE_CALL_TYPES
            and call_info.type in CALL_TYPE_TO_DOMAIN
        ):
            return CALL_TYPE_TO_DOMAIN[call_info.type]

        return "UNCLASSIFIED"

    @classmethod
    def _extract_service(cls, issues: list[Issue], turns: list[Turn]) -> Optional[str]:
        """Extract v2 SERVICE from issues."""
        if issues:
            issue_type = issues[0].type
            if issue_type in ISSUE_TO_SERVICE:
                return ISSUE_TO_SERVICE[issue_type]
        return None

    def _extract_customer_intent(
        self,
        issues: list[Issue],
        turns: list[Turn],
        actions: Optional[list[Action]] = None,
    ) -> tuple[Optional[str], Optional[str]]:
        """Extract v2 CUSTOMER_INTENT from customer turns using direct keyword matching.

        Scans customer turns only using CUSTOMER_INTENT_KEYWORDS (longest match first).
        Falls back to ISSUE_TO_INTENT only if direct keyword match fails.
        Applies context-based narrowing when generic intents are detected.

        Returns (primary_intent, secondary_intent).
        """
        primary = None
        secondary = None
        actions = actions or []

        # Primary: scan customer turns with direct keyword matching
        customer_text = " ".join(
            t.text.lower() for t in turns if t.speaker == "customer"
        )

        intents = self._lookup_all_categories(
            customer_text, self._customer_intent_index
        )
        if intents:
            primary = intents[0]
            if len(intents) > 1:
                secondary = intents[1]

        # Fallback 1: try agent turns (agents often restate the issue)
        if not primary:
            agent_text = " ".join(t.text.lower() for t in turns if t.speaker == "agent")
            intents = self._lookup_all_categories(
                agent_text, self._customer_intent_index
            )
            if intents:
                primary = intents[0]
                if len(intents) > 1:
                    secondary = intents[1]

        # Fallback 2: derive from issue type
        if not primary and issues:
            primary = ISSUE_TO_INTENT.get(issues[0].type)
            if len(issues) > 1 and not secondary:
                secondary = ISSUE_TO_INTENT.get(issues[1].type)

        # Context-based narrowing: refine generic intents using actions and issues
        if primary == "REPORT_BILLING_ISSUE":
            action_types = {a.type for a in actions}
            issue_types = {i.type for i in issues}
            if "REFUND_INITIATED" in action_types or "CREDIT_APPLIED" in action_types:
                primary = "REQUEST_REFUND"
            elif "DUPLICATE_CHARGE" in issue_types:
                primary = "REPORT_DUPLICATE_CHARGE"
            elif "UNEXPECTED_CHARGE" in issue_types:
                primary = "REPORT_UNEXPECTED_CHARGE"

        # Fallback 3: agent-action-based inference (if agent did refund, customer wanted refund)
        if not primary and actions:
            action_types = {a.type for a in actions}
            if "REFUND_INITIATED" in action_types or "CREDIT_APPLIED" in action_types:
                primary = "REQUEST_REFUND"

        return primary, secondary

    def _extract_context_provided(
        self, turns: list[Turn], call_info: Optional[CallInfo] = None
    ) -> list[str]:
        """Extract v2 CONTEXT tokens indicating what information the customer provided.

        Returns fact-of-information without leaking PII.
        """
        context = []
        seen = set()

        # Collect known agent names to exclude from NAME_PROVIDED
        agent_names = set()
        if call_info and call_info.agent:
            agent_names.add(call_info.agent.lower())

        def _add(token: str):
            if token not in seen:
                context.append(token)
                seen.add(token)

        for turn in turns:
            if turn.speaker != "customer":
                continue
            ents = getattr(turn, "entities", {}) or {}
            text_lower = turn.text.lower()

            if ents.get("emails"):
                _add("EMAIL_PROVIDED")

            if ents.get("phone_numbers"):
                _add("PHONE_NUMBER_PROVIDED")

            if ents.get("account_numbers") or ents.get("accounts"):
                _add("ACCOUNT_ID_PROVIDED")

            if ents.get("order_numbers"):
                _add("ORDER_ID_PROVIDED")

            if ents.get("tracking_numbers"):
                _add("TRACKING_ID_PROVIDED")

            if ents.get("money"):
                _add("PAYMENT_AMOUNT_PROVIDED")

            if ents.get("ticket_numbers"):
                _add("TICKET_ID_PROVIDED")

            if ents.get("case_numbers"):
                _add("CASE_ID_PROVIDED")

            if ents.get("product_models"):
                _add("PRODUCT_ID_PROVIDED")

            if ents.get("escalation_ids"):
                _add("ESCALATION_ID_PROVIDED")

            if ents.get("verification_codes"):
                _add("VERIFICATION_CODE_PROVIDED")

            # NAME_PROVIDED — detect via spaCy PERSON entity in customer turns
            # Exclude the agent's name (customer may thank them by name)
            doc = getattr(turn, "doc", None)
            if doc:
                for ent in doc.ents:
                    if ent.label_ == "PERSON":
                        name = ent.text.lower()
                        if name not in agent_names:
                            _add("NAME_PROVIDED")
                            break

            # Detect name introduction patterns
            intro_patterns = self.patterns.name_intro_patterns or [
                r"(?:my name is|i'?m|this is)\s+([A-Z][a-z]+)"
            ]
            if any(re.search(pat, text_lower) for pat in intro_patterns):
                _add("NAME_PROVIDED")

            # OLD_NAME_PROVIDED / NEW_NAME_PROVIDED — detect "change X to Y" patterns
            name_change_patterns = self.patterns.name_change_patterns or [
                r"\b(?:change|update)\s+(?:my\s+)?(?:name\s+)?(?:from\s+)?\w+\s+to\s+\w+"
            ]
            if any(re.search(pat, text_lower) for pat in name_change_patterns):
                _add("OLD_NAME_PROVIDED")
                _add("NEW_NAME_PROVIDED")

            # DELAY_N_DAYS — customer mentions a duration of waiting
            delay_match = re.search(r"\b(\d+)\s*days?\b", text_lower)
            delay_context = self.patterns.delay_context_words or [
                "waiting",
                "been",
                "ago",
                "since",
            ]
            if delay_match and any(w in text_lower for w in delay_context):
                days = delay_match.group(1)
                _add(f"DELAY_{days}_DAYS")

        return context

    def _extract_trigger_cause(self, turns: list[Turn]) -> Optional[str]:
        """Extract the trigger cause — why the customer contacted support.

        Scans only the first 3 customer turns for causal indicators (locked fields,
        missing delivery, price increases, etc.) using TRIGGER_CAUSE_KEYWORDS sorted
        longest-first. The trigger is the first actionable customer request and is
        locked after extraction — it is not inferred from the outcome.
        """
        trigger_index = sorted(
            [
                (kw.lower(), cause)
                for cause, kws in TRIGGER_CAUSE_KEYWORDS.items()
                for kw in kws
            ],
            key=lambda x: len(x[0]),
            reverse=True,
        )

        # Limit to the first 3 customer turns (workflow activation event)
        early_customer_turns = [t for t in turns if t.speaker == "customer"][:3]
        customer_text = " ".join(t.text.lower() for t in early_customer_turns)
        # Normalize typographic apostrophes (U+2019 ' → ') so keywords with
        # straight apostrophes ("hasn't", "aren't") match transcript text.
        customer_text = customer_text.replace("\u2019", "'")

        for kw, cause in trigger_index:
            if kw in customer_text:
                return cause

        return None

    @classmethod
    def _extract_system_actions(cls, turns: list[Turn]) -> list[str]:
        """Extract v2 SYSTEM_ACTIONS from system turns and agent references."""
        actions = []
        seen = set()

        system_text = " ".join(
            t.text.lower() for t in turns if t.speaker in ("system", "agent")
        )

        for action, keywords in SYSTEM_ACTION_KEYWORDS.items():
            if action not in seen and any(kw in system_text for kw in keywords):
                actions.append(action)
                seen.add(action)
        return actions
