"""
cortex/extraction/pipeline.py — ExtractionPipeline (Module 2 / M2).

The first stage of the remember() pipeline. Converts raw conversation turns
into structured CandidateClaim objects (subject-predicate-object triples)
that downstream modules can reconcile and persist.

Pipeline stages:
    1. CodingNoiseFilter (regex pre-filter — zero LLM cost)
    2. Batch splitting — divide filtered messages into chunks of batch_size
    3. For each batch: format_conversation() + LLM completion (structured JSON)
    4. JSON parse + validation → list of ClaimExtraction dataclasses (per batch)
    5. Aggregate all batch results into ExtractionResult

Does NOT write to storage — output feeds into ReconciliationEngine.reconcile() (M4).
"""

from __future__ import annotations

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

from cortex.extraction.coding_noise import CodingNoiseFilter
from cortex.extraction.json_utils import _repair_json
from cortex.extraction.models import ClaimExtraction, EntityMention, ExtractionResult
from cortex.extraction.predicate_filter import (
    filter_claims as _predicate_filter,
    cap_engineering_claims as _cap_engineering,
    filter_unknown_subjects as _filter_unknown_subjects,
)
from cortex.extraction.tautology_filter import filter_tautologies as _tautology_filter
from cortex.extraction.stale_plan_filter import filter_stale_plans as _stale_plan_filter
from cortex.extraction.prompts import (
    EXTRACTION_SYSTEM_PROMPT,
    format_conversation,
    USER_EXTRACTION_SYSTEM_PROMPT,
    AGENT_EXTRACTION_SYSTEM_PROMPT,
)
from cortex.extraction.prompts_v2 import (
    EXTRACTION_SYSTEM_PROMPT as EXTRACTION_SYSTEM_PROMPT_V2,
    format_conversation as format_conversation_v2,
)
from cortex.extraction.utils import (
    _is_llm_api_error,
    _cap_entities_per_extraction,
    _split_batches,
    dict_to_claim,
)

# Use the original module logger name for backwards compatibility — tests and
# log filters may capture "cortex.extraction.extractor" specifically.
logger = logging.getLogger("cortex.extraction.extractor")


class ExtractionPipeline:
    """
    Extracts structured claims from a conversation using LLM + noise filter.

    Supports batch extraction: when more messages than ``batch_size`` are
    provided, the pipeline splits them into chunks and issues one LLM call per
    chunk.  Claims from all batches are aggregated into a single
    ExtractionResult.  This prevents enormous context windows on long
    conversations while keeping per-call latency bounded.

    Usage::

        pipeline = ExtractionPipeline(llm=my_llm_provider, model="gpt-4.1-nano",
                                      batch_size=5)
        result = await pipeline.extract(messages, session_id="abc")
        for claim in result.claims:
            print(claim.action, claim.subject, claim.predicate,
                  claim.object_value, claim.temporal_marker)
    """

    DEFAULT_BATCH_SIZE = 5

    def __init__(
        self,
        llm: Any,
        model: Optional[str] = None,
        noise_filter: Optional[CodingNoiseFilter] = None,
        batch_size: int = DEFAULT_BATCH_SIZE,
        custom_instructions: str = "",
        max_claims_per_extraction: int = 40,
        max_entities_per_extraction: int = 100,
        prompt_version: str = "v1",
        known_subjects: Optional[list] = None,
        enable_stale_plan_filter: bool = True,
        exclude_categories: Optional[set] = None,
        extraction_mode: str = "unified",
    ) -> None:
        """Initialise the extraction pipeline.

        Args:
            llm: LLMProvider instance used for LLM completions.
            model: Optional model override forwarded to llm.complete().
            noise_filter: CodingNoiseFilter instance; a default is created when
                          None is passed.
            batch_size: Maximum number of messages per LLM call.  Must be ≥ 1.
                        Defaults to 5.  Set to a large number (e.g. 9999) to
                        disable batching and process all messages in one call.
            custom_instructions: Optional deployment-specific rules appended
                                 after EXTRACTION_SYSTEM_PROMPT.  When non-empty,
                                 the string is separated from the base prompt by a
                                 blank line and a section header for readability.
                                 Sourced from CortexConfig.extraction_custom_instructions.
        """
        if batch_size < 1:
            raise ValueError(f"batch_size must be >= 1, got {batch_size}")
        if max_claims_per_extraction < 0:
            raise ValueError(
                f"max_claims_per_extraction must be >= 0, got {max_claims_per_extraction}"
            )
        if max_entities_per_extraction < 0:
            raise ValueError(
                f"max_entities_per_extraction must be >= 0, got {max_entities_per_extraction}"
            )
        self.llm = llm
        self.model = model
        self.noise_filter = noise_filter or CodingNoiseFilter()
        self.batch_size = batch_size
        self.max_claims_per_extraction = max_claims_per_extraction
        self.max_entities_per_extraction = max_entities_per_extraction
        self.enable_stale_plan_filter = enable_stale_plan_filter
        self.extract_assistant_turns: bool = False
        self.extraction_temporal_markers: bool = True
        self.exclude_categories: set = {c.lower() for c in exclude_categories} if exclude_categories else set()
        self.extraction_mode: str = extraction_mode  # "unified" | "split" (#321)
        raw_instructions = custom_instructions.strip()

        # Sanitize custom_instructions to prevent prompt injection
        if raw_instructions:
            original_len = len(raw_instructions)
            # Cap length to prevent prompt flooding
            if len(raw_instructions) > 2000:
                raw_instructions = raw_instructions[:2000]
                logger.warning(
                    "ExtractionPipeline: custom_instructions truncated from %d to 2000 chars",
                    original_len,
                )

            # Strip common injection patterns (lines starting with control tokens)
            # Note: "Ignore" is only matched when followed by injection keywords to
            # avoid over-blocking legitimate instructions like "Ignore work topics."
            import re as _re
            _INJECTION_RE = _re.compile(
                r'^(##|System:|You are|Ignore\s+(previous|prior|all|above|the above|everything|instructions))',
                _re.IGNORECASE,
            )
            sanitized_lines = []
            removed_lines = []
            for line in raw_instructions.splitlines():
                stripped = line.lstrip()
                if _INJECTION_RE.match(stripped):
                    removed_lines.append(line)
                else:
                    sanitized_lines.append(line)
            if removed_lines:
                logger.warning(
                    "ExtractionPipeline: custom_instructions stripped %d potentially injected line(s): %r",
                    len(removed_lines),
                    removed_lines[:3],  # log up to 3 examples
                )
                raw_instructions = "\n".join(sanitized_lines)

        self.custom_instructions = raw_instructions
        self.prompt_version = prompt_version
        self.known_subjects: set = set(known_subjects) if known_subjects else set()

        # Select base prompt based on version
        _base_prompt = (
            EXTRACTION_SYSTEM_PROMPT_V2 if prompt_version == "v2"
            else EXTRACTION_SYSTEM_PROMPT
        )

        # Build the effective system prompt once (avoids repeated string ops per batch).
        if self.custom_instructions:
            self._system_prompt = (
                _base_prompt
                + "\n\n## Operator Instructions\n"
                + self.custom_instructions
            )
        else:
            self._system_prompt = _base_prompt

    async def extract(
        self,
        messages: List[Dict[str, Any]],
        session_id: str = "",
        session_date: Optional[str] = None,
        speaker_a: Optional[str] = None,
        speaker_b: Optional[str] = None,
    ) -> ExtractionResult:
        """Run the full extraction pipeline on a batch of conversation messages.

        Pipeline stages:
            1. CodingNoiseFilter — remove build output, diffs, stack traces (free)
            2. Batch splitting — divide filtered messages into chunks of batch_size
            3. For each chunk: format_conversation → LLM completion → JSON parse
            4. Aggregate claims from all chunks into a single ExtractionResult

        Args:
            messages: List of message dicts with 'role' and 'content' keys.
                      Accepts any dict shape; missing keys handled gracefully.
            session_id: Session identifier for logging context.

        Returns:
            ExtractionResult containing extracted claims, filter metrics, and
            the number of batches processed.

        Raises:
            Exception: Re-raised from LLM provider if any completion call fails.
                       The caller (CortexPlugin.remember) catches and logs this.

        Note:
            Output feeds into ReconciliationEngine.reconcile() (M4) via
            CortexPlugin.remember().
        """
        # Auto-wrap raw string inputs into proper message format (#602).
        if isinstance(messages, str):
            messages = [{"role": "user", "content": messages}]

        # Store session metadata for retry_with_split access
        self._current_session_date = session_date
        self._current_speaker_a = speaker_a
        self._current_speaker_b = speaker_b

        # Split mode: separate extraction passes for user vs agent messages (#321).
        if self.extraction_mode == "split":
            return await self._extract_split(
                messages,
                session_id=session_id,
                session_date=session_date,
                speaker_a=speaker_a,
                speaker_b=speaker_b,
            )

        raw_count = len(messages)

        # Step 1: filter noise
        filtered = self.noise_filter.filter_conversation(messages)
        noise_filtered = raw_count - len(filtered)

        # Gate: filter out assistant turns unless extract_assistant_turns is enabled (#1277).
        # Applied after noise filtering so noise_filtered count is accurate.
        if not self.extract_assistant_turns:
            filtered = [
                m for m in filtered
                if m.get("role", "user") != "assistant"
            ]

        if not filtered:
            logger.debug("session=%s all messages filtered as noise", session_id)
            return ExtractionResult(
                claims=[],
                noise_filtered=noise_filtered,
                raw_message_count=raw_count,
                batches_processed=0,
            )

        # Step 2: split into batches
        batches = _split_batches(filtered, self.batch_size)
        all_claims: List[ClaimExtraction] = []

        # Step 3: process each batch
        from cortex.llm.provider import _is_retryable_exception
        _failed_batches = 0

        for batch_idx, batch in enumerate(batches):
            if self.prompt_version == "v2":
                conversation_text = format_conversation_v2(
                    batch,
                    session_date=session_date,
                    speaker_a=speaker_a,
                    speaker_b=speaker_b,
                )
            else:
                conversation_text = format_conversation(batch)
            if not conversation_text.strip():
                logger.debug(
                    "session=%s batch=%d/%d empty after formatting — skipping",
                    session_id, batch_idx + 1, len(batches),
                )
                continue

            kwargs: Dict[str, Any] = {"response_format": "json", "max_tokens": 8192}
            # Model routing (#375): use routed model for extraction op
            from cortex.llm.router import resolve_routed_model
            _routed_model = resolve_routed_model(self.llm, "extraction", self.model)
            if _routed_model:
                kwargs["model"] = _routed_model

            try:
                _resp = await self.llm.complete(
                    system=self._system_prompt,
                    user=conversation_text,
                    **kwargs,
                )
                raw_json = _resp.text if hasattr(_resp, "text") else str(_resp)
            except Exception as exc:
                if _is_retryable_exception(exc) or _is_llm_api_error(exc):
                    _failed_batches += 1
                    logger.warning(
                        "session=%s batch=%d/%d LLM extraction failed (continuing): %s",
                        session_id, batch_idx + 1, len(batches), exc,
                    )
                    continue
                logger.error(
                    "session=%s batch=%d/%d extraction failed (non-retryable): %s",
                    session_id, batch_idx + 1, len(batches), exc,
                )
                raise

            logger.debug(
                "session=%s batch=%d/%d raw_llm_response=%r",
                session_id, batch_idx + 1, len(batches), raw_json,
            )
            batch_claims = self._parse_claims(raw_json, session_id)

            # Retry-with-split: if parse returned 0 claims but input was
            # non-empty, the JSON was likely too damaged. Split the batch
            # in half and retry each half independently (#643).
            if not batch_claims and len(batch) > 1:
                try:
                    batch_claims = await self._retry_with_split(
                        batch, kwargs, session_id, batch_idx, len(batches),
                    )
                except Exception as split_exc:
                    if _is_retryable_exception(split_exc) or _is_llm_api_error(split_exc):
                        _failed_batches += 1
                        logger.warning(
                            "session=%s batch=%d/%d retry-with-split failed (continuing): %s",
                            session_id, batch_idx + 1, len(batches), split_exc,
                        )
                        continue
                    raise

            # Post-extraction predicate filter: remove junk predicates/subjects
            batch_claims = _predicate_filter(batch_claims)
            # Tautology filter: remove self-evident / zero-information claims
            batch_claims = _tautology_filter(batch_claims, agent_name="eva")
            # Stale plan filter: remove planning claims with outdated temporal markers
            if self.enable_stale_plan_filter:
                batch_claims = _stale_plan_filter(batch_claims)
            # Category exclusion filter: drop claims in excluded categories
            if self.exclude_categories:
                pre_count = len(batch_claims)
                batch_claims = [
                    c for c in batch_claims
                    if str(getattr(c, "category", None) or (c.get("category") if isinstance(c, dict) else "misc")).lower()
                    not in self.exclude_categories
                ]
                dropped = pre_count - len(batch_claims)
                if dropped:
                    logger.info(
                        "Category filter dropped %d claims (excluded: %s)",
                        dropped, ", ".join(sorted(self.exclude_categories)),
                    )
            # Subject validation: reject claims about unknown third parties
            if self.known_subjects:
                batch_claims = _filter_unknown_subjects(batch_claims, self.known_subjects)
            batch_claims = _cap_engineering(batch_claims)

            # Temporal marker gate (#1276): strip temporal fields when disabled.
            if not self.extraction_temporal_markers:
                for c in batch_claims:
                    if hasattr(c, "temporal_marker"):
                        c.temporal_marker = None
                    if hasattr(c, "temporal"):
                        c.temporal = None
                    if hasattr(c, "event_date"):
                        c.event_date = None

            all_claims.extend(batch_claims)

            logger.debug(
                "session=%s batch=%d/%d extracted=%d",
                session_id, batch_idx + 1, len(batches), len(batch_claims),
            )

        # If ALL batches failed, raise so the caller knows extraction
        # completely failed (not just partial).
        if _failed_batches > 0 and _failed_batches >= len(batches):
            raise RuntimeError(
                f"session={session_id} all {len(batches)} extraction batches "
                f"failed due to LLM errors — no partial results available"
            )
        if _failed_batches > 0:
            logger.warning(
                "session=%s partial extraction: %d/%d batches failed",
                session_id, _failed_batches, len(batches),
            )

        if self.max_claims_per_extraction > 0 and len(all_claims) > self.max_claims_per_extraction:
            dropped_claims = len(all_claims) - self.max_claims_per_extraction
            logger.warning(
                "session=%s extraction claim cap applied: %d -> %d (dropped=%d)",
                session_id,
                len(all_claims),
                self.max_claims_per_extraction,
                dropped_claims,
            )
            # Priority-sort: keep high-salience, high-confidence claims
            SALIENCE_ORDER = {"high": 0, "medium": 1, "low": 2}
            all_claims.sort(key=lambda c: (
                SALIENCE_ORDER.get(getattr(c, 'salience', 'medium'), 1),
                -getattr(c, 'confidence', 0.5),
            ))
            all_claims = all_claims[:self.max_claims_per_extraction]

        if self.max_entities_per_extraction > 0:
            all_claims, trimmed_entities = _cap_entities_per_extraction(
                all_claims,
                self.max_entities_per_extraction,
            )
            if trimmed_entities > 0:
                logger.warning(
                    "session=%s extraction entity cap applied: trimmed=%d (max_entities_per_extraction=%d)",
                    session_id,
                    trimmed_entities,
                    self.max_entities_per_extraction,
                )

        logger.debug(
            "session=%s total_extracted=%d noise_filtered=%d batches=%d",
            session_id, len(all_claims), noise_filtered, len(batches),
        )
        return ExtractionResult(
            claims=all_claims,
            noise_filtered=noise_filtered,
            raw_message_count=raw_count,
            batches_processed=len(batches),
            batches_failed=_failed_batches,
        )

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _parse_claims(self, raw_json: str, session_id: str) -> List[ClaimExtraction]:
        """Parse LLM JSON output into ClaimExtraction list.

        Tolerant of common LLM output variations:
        - Bare JSON array: [...]
        - Wrapped in dict: {"claims": [...]}
        - Extra fields in claim objects (ignored)
        - Missing optional fields (defaults applied)
        - NOOP claims (silently skipped)

        Args:
            raw_json: Raw JSON string from LLM completion.
            session_id: Session identifier for log context.

        Returns:
            List of valid ClaimExtraction objects (NOOP filtered out).
        """
        raw_json = _repair_json(raw_json, session_id)

        try:
            data = json.loads(raw_json)
        except json.JSONDecodeError as exc:
            logger.warning("session=%s JSON parse error after repair: %s | raw=%r",
                           session_id, exc, raw_json[:200])
            return []

        if isinstance(data, dict):
            # Accept {"claims": [...]} wrapper — common LLM output shape
            if "claims" in data and isinstance(data["claims"], list):
                data = data["claims"]
            elif "subject" in data and "predicate" in data:
                # Single claim dict (not wrapped in a list) — wrap it
                data = [data]
            else:
                logger.warning("session=%s unexpected JSON dict shape (no 'claims' key): %r",
                               session_id, list(data.keys())[:5])
                return []

        if not isinstance(data, list):
            logger.warning("session=%s unexpected JSON shape (not a list): %r",
                           session_id, type(data))
            return []

        claims: List[ClaimExtraction] = []
        for item in data:
            if not isinstance(item, dict):
                continue
            try:
                claim = self._dict_to_claim(item)
                if claim.action == "NOOP":
                    continue  # skip NOOP — nothing to persist
                claims.append(claim)
            except (KeyError, TypeError, ValueError) as exc:
                logger.debug("session=%s skipping malformed claim %r: %s",
                             session_id, item, exc)
                continue

        return claims

    async def _retry_with_split(
        self,
        batch: List[Dict[str, Any]],
        kwargs: Dict[str, Any],
        session_id: str,
        batch_idx: int,
        total_batches: int,
    ) -> List[ClaimExtraction]:
        """Retry a failed batch by splitting it in half and extracting each half.

        Called when _parse_claims returns empty for a non-empty batch, suggesting
        the LLM output was too malformed to salvage. Splitting reduces per-call
        output size, making truncation less likely.

        Minimum batch size = 1 (single messages are not split further).
        """
        mid = len(batch) // 2
        halves = [batch[:mid], batch[mid:]]
        salvaged: List[ClaimExtraction] = []

        logger.warning(
            "session=%s batch=%d/%d retry-with-split: splitting %d messages into halves (%d, %d)",
            session_id, batch_idx + 1, total_batches,
            len(batch), len(halves[0]), len(halves[1]),
        )

        from cortex.llm.provider import _is_retryable_exception

        for half_idx, half in enumerate(halves):
            if not half:
                continue
            if self.prompt_version == "v2":
                conversation_text = format_conversation_v2(
                    half,
                    session_date=getattr(self, '_current_session_date', None),
                    speaker_a=getattr(self, '_current_speaker_a', None),
                    speaker_b=getattr(self, '_current_speaker_b', None),
                )
            else:
                conversation_text = format_conversation(half)
            if not conversation_text.strip():
                continue
            try:
                _resp = await self.llm.complete(
                    system=self._system_prompt,
                    user=conversation_text,
                    **kwargs,
                )
                raw_json = _resp.text if hasattr(_resp, "text") else str(_resp)
                half_claims = self._parse_claims(raw_json, session_id)
                salvaged.extend(half_claims)
                logger.info(
                    "session=%s batch=%d/%d split-half=%d extracted=%d claims",
                    session_id, batch_idx + 1, total_batches,
                    half_idx + 1, len(half_claims),
                )
            except Exception as exc:
                if _is_retryable_exception(exc) or _is_llm_api_error(exc):
                    logger.warning(
                        "session=%s batch=%d/%d split-half=%d LLM failed: %s",
                        session_id, batch_idx + 1, total_batches,
                        half_idx + 1, exc,
                    )
                else:
                    raise

        if salvaged:
            logger.warning(
                "session=%s batch=%d/%d retry-with-split salvaged %d claims total",
                session_id, batch_idx + 1, total_batches, len(salvaged),
            )
        return salvaged

    async def _extract_split(
        self,
        messages: List[Dict[str, Any]],
        session_id: str = "",
        session_date: Optional[str] = None,
        speaker_a: Optional[str] = None,
        speaker_b: Optional[str] = None,
    ) -> "ExtractionResult":
        """Split-mode extraction: separate LLM passes for user vs agent messages (#321).

        User messages are processed with USER_EXTRACTION_SYSTEM_PROMPT to extract
        preferences, facts, and personal history.  Agent/assistant messages are processed
        with AGENT_EXTRACTION_SYSTEM_PROMPT to extract capabilities and behavioral patterns.

        Claims from both passes are tagged with source_role='user' or source_role='agent'
        respectively, and aggregated into a single ExtractionResult.

        The noise filter, batch splitting, and all post-extraction filters still apply.
        """
        raw_count = len(messages)

        # Step 1: noise filter (shared across both passes)
        filtered = self.noise_filter.filter_conversation(messages)
        noise_filtered = raw_count - len(filtered)

        if not filtered:
            logger.debug("session=%s split-mode: all messages filtered as noise", session_id)
            return ExtractionResult(
                claims=[],
                noise_filtered=noise_filtered,
                raw_message_count=raw_count,
                batches_processed=0,
            )

        # Partition by role
        user_messages = [m for m in filtered if m.get("role", "user") not in ("assistant", "system")]
        agent_messages = [m for m in filtered if m.get("role", "user") == "assistant"]
        system_messages = [m for m in filtered if m.get("role", "user") == "system"]

        all_claims: List[ClaimExtraction] = []
        total_batches = 0

        # Helper: run one extraction pass for a given message set + prompt
        async def _run_pass(
            pass_messages: List[Dict[str, Any]],
            system_prompt: str,
            source_role: str,
        ) -> tuple:
            """Returns (claims, batches_processed)."""
            if not pass_messages:
                return [], 0

            batches = _split_batches(pass_messages, self.batch_size)
            pass_claims: List[ClaimExtraction] = []

            from cortex.llm.provider import _is_retryable_exception
            failed = 0

            for batch_idx, batch in enumerate(batches):
                if self.prompt_version == "v2":
                    conversation_text = format_conversation_v2(
                        batch,
                        session_date=session_date,
                        speaker_a=speaker_a,
                        speaker_b=speaker_b,
                    )
                else:
                    conversation_text = format_conversation(batch)

                if not conversation_text.strip():
                    continue

                kwargs: Dict[str, Any] = {"response_format": "json", "max_tokens": 8192}
                from cortex.llm.router import resolve_routed_model
                _routed_model = resolve_routed_model(self.llm, "extraction", self.model)
                if _routed_model:
                    kwargs["model"] = _routed_model

                try:
                    _resp = await self.llm.complete(
                        system=system_prompt,
                        user=conversation_text,
                        **kwargs,
                    )
                    raw_json = _resp.text if hasattr(_resp, "text") else str(_resp)
                except Exception as exc:
                    if _is_retryable_exception(exc) or _is_llm_api_error(exc):
                        failed += 1
                        logger.warning(
                            "session=%s split-mode(%s) batch=%d/%d LLM failed: %s",
                            session_id, source_role, batch_idx + 1, len(batches), exc,
                        )
                        continue
                    raise

                batch_claims = self._parse_claims(raw_json, session_id)

                # Retry-with-split on parse failure
                if not batch_claims and len(batch) > 1:
                    try:
                        batch_claims = await self._retry_with_split_prompt(
                            batch, kwargs, system_prompt, session_id, batch_idx, len(batches),
                        )
                    except Exception as split_exc:
                        if _is_retryable_exception(split_exc) or _is_llm_api_error(split_exc):
                            failed += 1
                            continue
                        raise

                # Tag source_role on each claim
                for c in batch_claims:
                    c.source_role = source_role

                # Apply post-extraction filters
                batch_claims = _predicate_filter(batch_claims)
                batch_claims = _tautology_filter(batch_claims, agent_name="eva")
                if self.enable_stale_plan_filter:
                    batch_claims = _stale_plan_filter(batch_claims)
                if self.exclude_categories:
                    pre_count = len(batch_claims)
                    batch_claims = [
                        c for c in batch_claims
                        if str(getattr(c, "category", None) or "misc").lower()
                        not in self.exclude_categories
                    ]
                    dropped = pre_count - len(batch_claims)
                    if dropped:
                        logger.info(
                            "Category filter dropped %d claims (excluded: %s)",
                            dropped, ", ".join(sorted(self.exclude_categories)),
                        )
                if self.known_subjects:
                    batch_claims = _filter_unknown_subjects(batch_claims, self.known_subjects)
                batch_claims = _cap_engineering(batch_claims)

                if not self.extraction_temporal_markers:
                    for c in batch_claims:
                        if hasattr(c, "temporal_marker"):
                            c.temporal_marker = None
                        if hasattr(c, "temporal"):
                            c.temporal = None
                        if hasattr(c, "event_date"):
                            c.event_date = None

                pass_claims.extend(batch_claims)

            return pass_claims, len(batches)

        # Run user pass
        user_claims, user_batches = await _run_pass(
            user_messages, USER_EXTRACTION_SYSTEM_PROMPT, "user"
        )
        all_claims.extend(user_claims)
        total_batches += user_batches

        # Run agent pass (only if extract_assistant_turns is enabled)
        if self.extract_assistant_turns and agent_messages:
            agent_claims, agent_batches = await _run_pass(
                agent_messages, AGENT_EXTRACTION_SYSTEM_PROMPT, "agent"
            )
            all_claims.extend(agent_claims)
            total_batches += agent_batches

        # Run system pass with unified prompt, tagged as 'system'
        if system_messages:
            system_claims, system_batches = await _run_pass(
                system_messages, self._system_prompt, "system"
            )
            all_claims.extend(system_claims)
            total_batches += system_batches

        # Apply global claim cap
        if self.max_claims_per_extraction > 0 and len(all_claims) > self.max_claims_per_extraction:
            dropped_claims = len(all_claims) - self.max_claims_per_extraction
            logger.warning(
                "session=%s split-mode extraction claim cap applied: %d -> %d (dropped=%d)",
                session_id, len(all_claims), self.max_claims_per_extraction, dropped_claims,
            )
            SALIENCE_ORDER = {"high": 0, "medium": 1, "low": 2}
            all_claims.sort(key=lambda c: (
                SALIENCE_ORDER.get(getattr(c, 'salience', 'medium'), 1),
                -getattr(c, 'confidence', 0.5),
            ))
            all_claims = all_claims[:self.max_claims_per_extraction]

        if self.max_entities_per_extraction > 0:
            all_claims, trimmed_entities = _cap_entities_per_extraction(
                all_claims, self.max_entities_per_extraction,
            )
            if trimmed_entities > 0:
                logger.warning(
                    "session=%s split-mode entity cap trimmed=%d",
                    session_id, trimmed_entities,
                )

        logger.debug(
            "session=%s split-mode total_extracted=%d (user=%d, agent=%d) noise_filtered=%d batches=%d",
            session_id, len(all_claims), len(user_claims),
            len(all_claims) - len(user_claims), noise_filtered, total_batches,
        )

        return ExtractionResult(
            claims=all_claims,
            noise_filtered=noise_filtered,
            raw_message_count=raw_count,
            batches_processed=total_batches,
        )

    async def _retry_with_split_prompt(
        self,
        batch: List[Dict[str, Any]],
        kwargs: Dict[str, Any],
        system_prompt: str,
        session_id: str,
        batch_idx: int,
        total_batches: int,
    ) -> List[ClaimExtraction]:
        """Retry-with-split variant that accepts an explicit system_prompt (for split mode)."""
        mid = len(batch) // 2
        halves = [batch[:mid], batch[mid:]]
        salvaged: List[ClaimExtraction] = []

        from cortex.llm.provider import _is_retryable_exception

        for half_idx, half in enumerate(halves):
            if not half:
                continue
            if self.prompt_version == "v2":
                conversation_text = format_conversation_v2(
                    half,
                    session_date=getattr(self, '_current_session_date', None),
                    speaker_a=getattr(self, '_current_speaker_a', None),
                    speaker_b=getattr(self, '_current_speaker_b', None),
                )
            else:
                conversation_text = format_conversation(half)
            if not conversation_text.strip():
                continue
            try:
                _resp = await self.llm.complete(
                    system=system_prompt,
                    user=conversation_text,
                    **kwargs,
                )
                raw_json = _resp.text if hasattr(_resp, "text") else str(_resp)
                half_claims = self._parse_claims(raw_json, session_id)
                salvaged.extend(half_claims)
            except Exception as exc:
                if _is_retryable_exception(exc) or _is_llm_api_error(exc):
                    logger.warning(
                        "session=%s batch=%d/%d split-prompt retry half=%d LLM failed: %s",
                        session_id, batch_idx + 1, total_batches, half_idx + 1, exc,
                    )
                else:
                    raise

        return salvaged

    @staticmethod
    def _dict_to_claim(d: Dict[str, Any]) -> ClaimExtraction:
        """Convert a raw LLM dict to a ClaimExtraction. Delegates to module-level function."""
        return dict_to_claim(d)
