from __future__ import annotations

import asyncio
import concurrent.futures
import json
import logging
import re
import time
import unicodedata
from collections import Counter, defaultdict
from collections.abc import (
    AsyncGenerator,
    AsyncIterator,
    Awaitable,
    Callable,
    Iterable,
    Iterator,
    Sequence,
)
from dataclasses import dataclass, field, replace
from decimal import Decimal, InvalidOperation
from typing import Any, Literal

from pydantic import TypeAdapter, ValidationError
from pydantic_core import to_jsonable_python

from parsantic.ai import slice_doc_for_paths, slice_schema_for_paths, validation_error_paths
from parsantic.api import ParseResult, parse
from parsantic.api import parse_stream as make_stream_parser
from parsantic.coerce import CoerceOptions
from parsantic.config import resolve_model
from parsantic.json_pointer import (
    build_json_pointer,
    escape_json_pointer_token,
    parse_json_pointer,
)
from parsantic.jsonish import ParseOptions
from parsantic.provider_output import normalize_text_outputs

from .alignment import AlignmentOptions, align_value_to_text
from .chunking import TextChunk, iter_chunks
from .diagnostics import FieldDiagnostic, FieldState
from .formatting import FormatHandler
from .media.chunking import MediaChunk, chunk_attachments, needs_media
from .media.preflight import PreflightResult, analyze_pdf_source
from .options import ExtractOptions, ResolvedStrategy
from .prompt import Example, Prompt, PromptValidationLevel
from .providers.base import (
    InferenceRequest,
    ProviderConfig,
    SupportsAsyncInferStream,
    SupportsAsyncMediaInfer,
    SupportsAsyncMediaInferStream,
    SupportsInferStream,
    SupportsMediaInfer,
    SupportsMediaInferStream,
)
from .providers.factory import create_provider
from .repair import (
    RepairContext,
    build_repair_prompt,
    collect_validation_errors,
    format_broken_output,
)
from .schema import PydanticSchemaAdapter
from .tokenizer import Tokenizer, TokenizerName, get_tokenizer
from .types import (
    AlignmentStatus,
    ChunkDebug,
    Document,
    DocumentPageSpan,
    ExtractDebug,
    ExtractResult,
    ExtractStreamEvent,
    FieldEvidence,
    FieldStatus,
    MergeConflict,
    SourceRef,
    SupportStatus,
)

logger = logging.getLogger(__name__)


_DEFAULT_DESCRIPTION = "Extract structured data that matches the provided schema."
_MISSING = object()
_STRICT_PAGE_ALIGNMENT = AlignmentOptions(enable_fuzzy_alignment=False, accept_match_lesser=False)
type _RootKind = Literal["object", "array", "scalar"]


def _iter_leaf_values(value: Any, path: str = "") -> Iterator[tuple[str, str]]:
    if value is None:
        return
    if isinstance(value, dict):
        for key, val in value.items():
            next_path = f"{path}/{escape_json_pointer_token(str(key))}"
            yield from _iter_leaf_values(val, next_path)
        return
    if isinstance(value, list):
        for idx, val in enumerate(value):
            next_path = f"{path}/{idx}"
            yield from _iter_leaf_values(val, next_path)
        return
    if isinstance(value, (str, int, float, bool)):
        yield (path or "/", str(value))


def _pointer_tokens(path: str) -> tuple[str, ...]:
    if path in {"", "/"}:
        return ()
    return tuple(parse_json_pointer(path))


def _pointer_from_tokens(tokens: Sequence[str]) -> str:
    return build_json_pointer(list(tokens)) or "/"


def _child_pointer(path: str, token: str | int) -> str:
    token_text = escape_json_pointer_token(str(token))
    if path in {"", "/"}:
        return f"/{token_text}"
    return f"{path}/{token_text}"


def _collect_leaf_path_map(
    value: Any,
    *,
    source_path: str,
    target_path: str,
) -> dict[str, str]:
    if value is None:
        return {}
    if isinstance(value, dict):
        mapping: dict[str, str] = {}
        for key, child in value.items():
            mapping.update(
                _collect_leaf_path_map(
                    child,
                    source_path=_child_pointer(source_path, key),
                    target_path=_child_pointer(target_path, key),
                )
            )
        return mapping
    if isinstance(value, list):
        mapping = {}
        for idx, child in enumerate(value):
            mapping.update(
                _collect_leaf_path_map(
                    child,
                    source_path=_child_pointer(source_path, idx),
                    target_path=_child_pointer(target_path, idx),
                )
            )
        return mapping
    return {target_path or "/": source_path or "/"}


def _remap_leaf_evidence(
    evidence: Sequence[FieldEvidence],
    leaf_map: dict[str, str],
) -> list[FieldEvidence]:
    if not leaf_map:
        return []
    source_to_target = {source_path: target_path for target_path, source_path in leaf_map.items()}
    remapped: list[FieldEvidence] = []
    for ev in evidence:
        target_path = source_to_target.get(ev.path)
        if target_path is None:
            continue
        remapped.append(
            FieldEvidence(
                path=target_path,
                value_preview=ev.value_preview,
                char_interval=ev.char_interval,
                token_interval=ev.token_interval,
                alignment_status=ev.alignment_status,
                source=ev.source,
                attachment_index=ev.attachment_index,
                page_index=ev.page_index,
                bbox_norm=ev.bbox_norm,
                grounding_method=ev.grounding_method,
            )
        )
    return remapped


def _is_record_like_list(items: Sequence[Any]) -> bool:
    return any(isinstance(item, (dict, list)) for item in items)


def _merge_branch_values(
    base: Any,
    incoming: Any,
    *,
    strategy: Literal["first_wins", "last_wins", "prefer_non_null"] = "first_wins",
    conflicts: list[MergeConflict] | None = None,
    path: str = "/",
    page_index: int | None = None,
) -> tuple[Any, dict[str, str]]:
    if base is None:
        return incoming, _collect_leaf_path_map(incoming, source_path=path, target_path=path)
    if incoming is None:
        return base, {}

    if isinstance(base, list) and isinstance(incoming, list):
        leaf_map: dict[str, str]
        if not _is_record_like_list([*base, *incoming]):
            merged = list(base)
            leaf_map = {}
            seen: Counter[str] = Counter()
            for item in merged:
                try:
                    seen[json.dumps(item, sort_keys=True, default=str)] += 1
                except (ValueError, TypeError):
                    continue
            for idx, item in enumerate(incoming):
                try:
                    key = json.dumps(item, sort_keys=True, default=str)
                except (ValueError, TypeError):
                    key = None
                if key is not None and seen[key] > 0:
                    seen[key] -= 1
                    continue
                target_index = len(merged)
                merged.append(item)
                leaf_map.update(
                    _collect_leaf_path_map(
                        item,
                        source_path=_child_pointer(path, idx),
                        target_path=_child_pointer(path, target_index),
                    )
                )
            return merged, leaf_map

        merged = list(base)
        leaf_map = {}
        offset = len(base)
        for idx, item in enumerate(incoming):
            merged.append(item)
            source_path = _child_pointer(path, idx)
            target_path = _child_pointer(path, offset + idx)
            leaf_map.update(
                _collect_leaf_path_map(
                    item,
                    source_path=source_path,
                    target_path=target_path,
                )
            )
        return merged, leaf_map

    if isinstance(base, dict) and isinstance(incoming, dict):
        merged = dict(base)
        leaf_map = {}
        for key, val in incoming.items():
            child_path = _child_pointer(path, key)
            if key in merged:
                merged[key], child_map = _merge_branch_values(
                    merged[key],
                    val,
                    strategy=strategy,
                    conflicts=conflicts,
                    path=child_path,
                    page_index=page_index,
                )
                leaf_map.update(child_map)
            else:
                merged[key] = val
                leaf_map.update(
                    _collect_leaf_path_map(
                        val,
                        source_path=child_path,
                        target_path=child_path,
                    )
                )
        return merged, leaf_map

    if base != incoming and conflicts is not None:
        conflicts.append(
            MergeConflict(
                path=path or "/",
                existing_preview=str(base)[:80],
                incoming_preview=str(incoming)[:80],
                page_index=page_index,
            )
        )

    use_incoming = False
    if strategy == "last_wins":
        use_incoming = True
    elif strategy == "prefer_non_null":
        if base in (None, ""):
            use_incoming = True
        elif incoming in (None, ""):
            use_incoming = False
        else:
            use_incoming = False

    chosen = incoming if use_incoming else base
    leaf_map = (
        _collect_leaf_path_map(incoming, source_path=path, target_path=path) if use_incoming else {}
    )
    return chosen, leaf_map


def _normalize_prompt(prompt: Prompt | str | None) -> Prompt:
    if prompt is None:
        return Prompt(description=_DEFAULT_DESCRIPTION)
    if isinstance(prompt, str):
        return Prompt(description=prompt)
    return prompt


def _schema_root_kind(schema: dict[str, Any]) -> _RootKind | None:
    schema_type = schema.get("type")
    if schema_type == "array":
        return "array"
    if schema_type == "object":
        return "object"
    if schema_type in {"string", "number", "integer", "boolean", "null"}:
        return "scalar"
    for key in ("anyOf", "oneOf", "allOf"):
        variants = schema.get(key)
        if not isinstance(variants, list) or not variants:
            continue
        variant_kinds = {_schema_root_kind(variant) for variant in variants}
        if len(variant_kinds) == 1 and None not in variant_kinds:
            return next(iter(variant_kinds))
        if variant_kinds and variant_kinds <= {"scalar", None}:
            return "scalar"
    if "const" in schema:
        const_value = schema["const"]
        if isinstance(const_value, (str, int, float, bool)) or const_value is None:
            return "scalar"
    if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]:
        if all(
            isinstance(enum_value, (str, int, float, bool)) or enum_value is None
            for enum_value in schema["enum"]
        ):
            return "scalar"
    return None


def _validate_examples(
    prompt: Prompt,
    adapter: PydanticSchemaAdapter,
    *,
    tokenizer: TokenizerName | Tokenizer | None,
    alignment: AlignmentOptions,
    level: PromptValidationLevel,
) -> None:
    if level == PromptValidationLevel.OFF:
        return
    tok = get_tokenizer(tokenizer)
    errors: list[str] = []
    for idx, ex in enumerate(prompt.examples):
        try:
            validated = adapter.validate(ex.output)
            dumped = adapter.dump(validated)
        except (ValidationError, ValueError) as exc:  # pragma: no cover - surfaced in tests
            errors.append(f"example#{idx} failed schema validation: {exc}")
            continue
        tokenized_source = tok.tokenize(ex.text)
        for path, text in _iter_leaf_values(dumped):
            evidence = align_value_to_text(
                ex.text,
                path,
                text,
                tokenizer=tok,
                options=alignment,
                tokenized_source=tokenized_source,
            )
            if evidence.char_interval is None:
                errors.append(f"example#{idx} path {path} value '{text}' not found in example text")
    if errors and level == PromptValidationLevel.ERROR:
        raise ValueError("Prompt validation failed: " + "; ".join(errors))
    if errors and level == PromptValidationLevel.WARNING:
        import warnings

        warnings.warn("Prompt validation warnings: " + "; ".join(errors), stacklevel=2)


def _render_prompt(
    prompt: Prompt,
    *,
    schema_text: str | None,
    examples: Sequence[Example],
    question: str,
    format_handler: FormatHandler,
    additional_context: str | None,
    output_kind: _RootKind | None = None,
    native_mode: bool = False,
) -> str:
    lines: list[str] = [prompt.description.strip(), ""]
    if additional_context:
        lines.append(additional_context)
        lines.append("")

    if not native_mode:
        # Extraction quality instructions
        lines.append(
            "Use exact values as they appear in the source. "
            "Normalize dates to ISO 8601 format (YYYY-MM-DD). "
            "If a value cannot be clearly identified, use null."
        )
        lines.append("")
        # E5: Add explicit output format instructions
        fmt = format_handler.options.format.lower() if format_handler.options else "json"
        if fmt == "json":
            expected_kind = output_kind or "object"
            if format_handler.options and format_handler.options.wrapper_key:
                expected_kind = "object"
            expected_label = "value" if expected_kind == "scalar" else expected_kind
            lines.append(
                f"Output a single JSON {expected_label}. "
                "Do not include any surrounding prose or commentary."
            )
            if format_handler.options and format_handler.options.wrapper_key:
                lines.append(
                    f'Wrap the result list under the key "{format_handler.options.wrapper_key}".'
                )
            lines.append("")
        elif fmt == "yaml":
            lines.append("Output YAML only. Do not include any surrounding prose or commentary.")
            if format_handler.options and format_handler.options.wrapper_key:
                lines.append(
                    f'Wrap the result list under the key "{format_handler.options.wrapper_key}".'
                )
            lines.append("")

        if schema_text:
            lines.append("Schema:")
            lines.append(schema_text)
            lines.append("")

    if examples:
        if native_mode:
            for ex in examples:
                formatted = format_handler.format_example(ex.output)
                lines.append("Example:")
                lines.append(f"{ex.text} \u2192 {formatted}")
                lines.append("")
        else:
            lines.append("Examples")
            for ex in examples:
                formatted = format_handler.format_example(ex.output)
                lines.append(f"Q: {ex.text}")
                lines.append("A: " + formatted)
                lines.append("")

    if native_mode:
        lines.append("---")
        lines.append(question)
    else:
        lines.append(f"Q: {question}")
        lines.append("A:")
    return "\n".join(lines)


def _merge_values(
    base: Any,
    other: Any,
    *,
    strategy: Literal["first_wins", "last_wins", "prefer_non_null"] = "first_wins",
    conflicts: list[MergeConflict] | None = None,
    path: str = "",
    page_index: int | None = None,
) -> Any:
    if base is None:
        return other
    if other is None:
        return base
    if isinstance(base, list) and isinstance(other, list):
        if page_index is not None:
            return [*base, *other]

        merged = list(base)
        seen: Counter[str] = Counter()
        for item in merged:
            try:
                seen[json.dumps(item, sort_keys=True, default=str)] += 1
            except (ValueError, TypeError):
                continue
        for item in other:
            try:
                key = json.dumps(item, sort_keys=True, default=str)
            except (ValueError, TypeError):
                key = None
            if key is None:
                merged.append(item)
                continue
            if seen[key] > 0:
                seen[key] -= 1
                continue
            merged.append(item)
        return merged
    if isinstance(base, dict) and isinstance(other, dict):
        merged = dict(base)
        for key, val in other.items():
            child_path = f"{path}/{escape_json_pointer_token(str(key))}"
            if key in merged:
                merged[key] = _merge_values(
                    merged[key],
                    val,
                    strategy=strategy,
                    conflicts=conflicts,
                    path=child_path,
                    page_index=page_index,
                )
            else:
                merged[key] = val
        return merged
    # Scalar conflict: both base and other are non-None scalars with different values
    if base != other and conflicts is not None:
        conflicts.append(
            MergeConflict(
                path=path or "/",
                existing_preview=str(base)[:80],
                incoming_preview=str(other)[:80],
                page_index=page_index,
            )
        )
    if strategy == "last_wins":
        return other
    if strategy == "prefer_non_null":
        if base in (None, ""):
            return other
        if other in (None, ""):
            return base
    return base


def _align_evidence(
    source_text: str,
    value: Any,
    *,
    tokenizer: TokenizerName | Tokenizer | None,
    alignment: AlignmentOptions,
    offset: int,
) -> list[FieldEvidence]:
    tok = get_tokenizer(tokenizer)
    tokenized_source = tok.tokenize(source_text)
    evidence: list[FieldEvidence] = []
    for path, text in _iter_leaf_values(value):
        ev = align_value_to_text(
            source_text,
            path,
            text,
            tokenizer=tok,
            options=alignment,
            tokenized_source=tokenized_source,
        )
        if ev.char_interval:
            start, end = ev.char_interval
            ev = FieldEvidence(
                path=ev.path,
                value_preview=ev.value_preview,
                char_interval=(start + offset, end + offset),
                # token_interval stays chunk-relative: offsetting would require
                # retokenising the full document (token boundaries differ).
                token_interval=ev.token_interval,
                alignment_status=ev.alignment_status,
            )
        evidence.append(ev)
    return evidence


@dataclass(slots=True)
class _ExtractionContext:
    """Shared setup computed once for both sync and async extraction paths."""

    documents: list[Document]
    prompt_obj: Prompt
    opts: ExtractOptions
    adapter: PydanticSchemaAdapter[Any]
    format_handler: FormatHandler
    schema_text: str | None
    output_kind: _RootKind | None
    normalized_examples: list[Example]
    provider: Any
    resolved_strategy: ResolvedStrategy
    budget: _BudgetState
    target_type: type[Any] | None = None
    use_native_schema: bool = False
    original_pdf_attachments: tuple[Any, ...] = ()


@dataclass(slots=True)
class _BudgetState:
    opts: ExtractOptions
    network_workers: int
    api_calls_used: int = 0
    consecutive_server_errors: int = 0
    document_deadline: float | None = None

    def ensure_api_budget(self, calls: int) -> None:
        if self.opts.max_api_calls is None:
            return
        if self.api_calls_used + calls > self.opts.max_api_calls:
            raise RuntimeError(
                f"LLM call budget exceeded: {self.api_calls_used + calls} > {self.opts.max_api_calls}"
            )
        self.api_calls_used += calls

    def apply_rate_limit_backoff(self) -> None:
        self.network_workers = max(1, self.network_workers // 2)

    def record_server_error(self) -> None:
        self.consecutive_server_errors += 1

    def reset_server_errors(self) -> None:
        self.consecutive_server_errors = 0


def _check_document_deadline(ctx: _ExtractionContext) -> None:
    deadline = ctx.budget.document_deadline
    if deadline is not None and time.monotonic() > deadline:
        raise TimeoutError("Document timeout exceeded before dispatching the next request")


def _run_sync_call_with_timeout(
    call: Callable[[], Any],
    *,
    timeout_s: float | None,
) -> Any:
    if timeout_s is None:
        return call()
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        future = executor.submit(call)
        return future.result(timeout=timeout_s)


def _should_bypass_sync_thread_timeout(provider: Any, *, media: bool) -> bool:
    if media:
        return isinstance(provider, SupportsAsyncMediaInfer)
    return hasattr(provider, "ainfer")


async def _run_async_call_with_timeout(
    call: Callable[[], Awaitable[Any]],
    *,
    timeout_s: float | None,
) -> Any:
    if timeout_s is None:
        return await call()
    return await asyncio.wait_for(call(), timeout=timeout_s)


def _record_inference_success(ctx: _ExtractionContext) -> None:
    ctx.budget.reset_server_errors()


def _maybe_handle_provider_error(ctx: _ExtractionContext, exc: Exception) -> None:
    message = str(exc)
    if "429" in message:
        ctx.budget.apply_rate_limit_backoff()
        logger.warning(
            "Provider rate limited; lowering network workers to %d", ctx.budget.network_workers
        )
        return
    if re.search(r"\b5\d\d\b", message):
        ctx.budget.record_server_error()
        if ctx.budget.consecutive_server_errors >= 2:
            logger.warning(
                "Repeated provider 5xx responses; future PDF work may fall back to text-only"
            )


def _build_extraction_context(
    text_or_documents: str | Document | Iterable[Document],
    target: type[Any] | TypeAdapter[Any],
    *,
    model: str | Any | None,
    prompt: Prompt | str | None,
    options: ExtractOptions | None,
    provider_kwargs: dict[str, Any] | None,
) -> _ExtractionContext:
    """Shared setup logic for both sync and async extraction."""
    if isinstance(text_or_documents, str):
        documents = [Document(text=text_or_documents)]
    elif isinstance(text_or_documents, Document):
        documents = [text_or_documents]
    else:
        documents = list(text_or_documents)

    prompt_obj = _normalize_prompt(prompt)
    opts = options or ExtractOptions()
    resolved_strategy = opts.resolve_runtime_strategy()
    opts = replace(
        opts,
        mode=None,
        document_input="auto",
        page_input="auto",
        media=resolved_strategy.media,
        strategy=None,
    )
    adapter = PydanticSchemaAdapter.from_target(target)
    format_handler = FormatHandler(opts.format)
    schema_obj = adapter.adapter.json_schema()

    _validate_examples(
        prompt_obj,
        adapter,
        tokenizer=opts.tokenizer,
        alignment=opts.alignment,
        level=opts.prompt_validation,
    )

    schema_text: str | None = None
    if prompt_obj.include_schema:
        if opts.schema_mode == "compact":
            schema_text = json.dumps(schema_obj, ensure_ascii=False)
        else:
            schema_text = json.dumps(schema_obj, indent=2, ensure_ascii=False)
    output_kind = _schema_root_kind(schema_obj)

    normalized_examples = [
        Example(text=ex.text, output=adapter.dump(adapter.validate(ex.output)))
        for ex in prompt_obj.examples
    ]

    resolved_model = resolve_model(model)
    logger.debug(
        "Building extraction context: model=%s, documents=%d",
        resolved_model if isinstance(resolved_model, str) else type(resolved_model).__name__,
        len(documents),
    )
    provider = (
        resolved_model
        if not isinstance(resolved_model, str)
        else create_provider(
            ProviderConfig(model_id=resolved_model, provider_kwargs=provider_kwargs or {})
        )
    )

    # Determine if we should use native structured output
    structured_mode = opts.structured_output
    supports = getattr(provider, "supports_native_structured_output", None)
    provider_supports = callable(supports) and supports()

    use_native = False
    if structured_mode == "native":
        if provider_supports:
            use_native = True
        else:
            logger.warning(
                "structured_output='native' requested but provider %s does not "
                "support native structured output; falling back to prompt mode",
                type(provider).__name__,
            )
    elif structured_mode == "auto":
        use_native = provider_supports

    # Resolve target_type for native structured output
    target_type: type[Any] | None = None
    if use_native:
        if isinstance(target, type):
            target_type = target
        elif isinstance(target, TypeAdapter):
            target_type = None  # Can't resolve type from TypeAdapter reliably
        if target_type is None:
            use_native = False

    if use_native:
        logger.debug("Native structured output enabled for %s", type(provider).__name__)
        schema_text = None  # redundant when provider constrains output via its own schema

    # Capture original PDF attachments for post-extraction bbox enrichment
    # (text extraction may strip them from the working document).
    original_pdf_atts: tuple[Any, ...] = ()
    for doc in documents:
        if doc.attachments:
            original_pdf_atts = doc.attachments
            break

    return _ExtractionContext(
        documents=documents,
        prompt_obj=prompt_obj,
        opts=opts,
        adapter=adapter,
        format_handler=format_handler,
        schema_text=schema_text,
        output_kind=output_kind,
        normalized_examples=normalized_examples,
        provider=provider,
        resolved_strategy=resolved_strategy,
        budget=_BudgetState(
            opts=opts,
            network_workers=max(1, opts.concurrency.network_workers or opts.max_workers),
        ),
        target_type=target_type,
        use_native_schema=use_native,
        original_pdf_attachments=original_pdf_atts,
    )


@dataclass(slots=True)
class Extractor:
    model: str | Any | None = None
    prompt: Prompt | str | None = None
    options: ExtractOptions | None = None
    provider_kwargs: dict[str, Any] | None = None

    def __enter__(self) -> Extractor:
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    async def __aenter__(self) -> Extractor:
        return self

    async def __aexit__(self, exc_type, exc, tb) -> bool:
        return False

    def extract[T](
        self,
        text: str | Document | Iterable[Document],
        target: type[T] | TypeAdapter[T],
        *,
        parse_options: ParseOptions | None = None,
        coerce_options: CoerceOptions | None = None,
        debug: bool = False,
    ) -> ExtractResult[T]:
        return extract(
            text,
            target,
            model=self.model,
            prompt=self.prompt,
            options=self.options,
            provider_kwargs=self.provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )

    def extract_stream[T](
        self,
        text_or_document: str | Document,
        target: type[T] | TypeAdapter[T],
        *,
        parse_options: ParseOptions | None = None,
        coerce_options: CoerceOptions | None = None,
        debug: bool = False,
    ) -> Iterator[ExtractStreamEvent[T]]:
        return extract_stream(
            text_or_document,
            target,
            model=self.model,
            prompt=self.prompt,
            options=self.options,
            provider_kwargs=self.provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )

    async def aextract[T](
        self,
        text_or_documents: str | Document | Iterable[Document],
        target: type[T] | TypeAdapter[T],
        *,
        parse_options: ParseOptions | None = None,
        coerce_options: CoerceOptions | None = None,
        debug: bool = False,
    ) -> ExtractResult[T] | list[ExtractResult[T]]:
        return await aextract(
            text_or_documents,
            target,
            model=self.model,
            prompt=self.prompt,
            options=self.options,
            provider_kwargs=self.provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )

    async def aextract_stream[T](
        self,
        text_or_document: str | Document,
        target: type[T] | TypeAdapter[T],
        *,
        parse_options: ParseOptions | None = None,
        coerce_options: CoerceOptions | None = None,
        debug: bool = False,
    ) -> AsyncIterator[ExtractStreamEvent[T]]:
        async for event in aextract_stream(
            text_or_document,
            target,
            model=self.model,
            prompt=self.prompt,
            options=self.options,
            provider_kwargs=self.provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        ):
            yield event


def extract[T](
    text_or_documents: str | Document | Iterable[Document],
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> ExtractResult[T] | list[ExtractResult[T]]:
    results = list(
        extract_iter(
            text_or_documents,
            target,
            model=model,
            prompt=prompt,
            options=options,
            provider_kwargs=provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    )
    if isinstance(text_or_documents, (str, Document)):
        return results[0]
    return results


def _infer_batch(
    ctx_or_provider: _ExtractionContext | Any,
    provider_or_prompts: Any,
    prompts_or_batch_length: Sequence[str] | int,
    batch_length: int | None = None,
    **infer_kwargs: Any,
) -> list[str]:
    """Call provider.infer in batches of *batch_length* and concatenate results."""
    if isinstance(ctx_or_provider, _ExtractionContext):
        ctx = ctx_or_provider
        provider = provider_or_prompts
        prompts = prompts_or_batch_length
        batch_length = batch_length if batch_length is not None else 1
    else:
        ctx = None
        provider = ctx_or_provider
        prompts = provider_or_prompts
        batch_length = (
            prompts_or_batch_length
            if isinstance(prompts_or_batch_length, int)
            else batch_length
            if batch_length is not None
            else 1
        )
    logger.debug("Batched inference: %d prompts, batch_length=%d", len(prompts), batch_length)
    all_outputs: list[str] = []
    for i in range(0, len(prompts), batch_length):
        batch = prompts[i : i + batch_length]
        if ctx is None:
            outputs = normalize_text_outputs(
                provider.infer(batch, **infer_kwargs),
                expected_count=len(batch),
                context="provider.infer",
            )
        else:
            _check_document_deadline(ctx)
            ctx.budget.ensure_api_budget(1)
            try:
                raw = (
                    provider.infer(batch, **infer_kwargs)
                    if _should_bypass_sync_thread_timeout(provider, media=False)
                    else _run_sync_call_with_timeout(
                        lambda batch=batch: provider.infer(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                )
                outputs = normalize_text_outputs(
                    raw,
                    expected_count=len(batch),
                    context="provider.infer",
                )
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise
        all_outputs.extend(outputs)
    return all_outputs


def _infer_batch_parallel(
    ctx_or_provider: _ExtractionContext | Any,
    provider_or_prompts: Any,
    prompts_or_batch_length: Sequence[str] | int,
    batch_length_or_workers: int,
    max_workers: int | None = None,
    **infer_kwargs: Any,
) -> list[str]:
    """Call provider.infer in batches of *batch_length* with ThreadPoolExecutor."""
    if isinstance(ctx_or_provider, _ExtractionContext):
        ctx = ctx_or_provider
        provider = provider_or_prompts
        prompts = prompts_or_batch_length
        batch_length = batch_length_or_workers
        max_workers = max_workers if max_workers is not None else 1
    else:
        ctx = None
        provider = ctx_or_provider
        prompts = provider_or_prompts
        batch_length = (
            prompts_or_batch_length
            if isinstance(prompts_or_batch_length, int)
            else batch_length_or_workers
        )
        max_workers = batch_length_or_workers if max_workers is None else max_workers
    batches: list[Sequence[str]] = []
    batch_length = max(1, batch_length)
    for i in range(0, len(prompts), batch_length):
        batches.append(prompts[i : i + batch_length])
    logger.debug(
        "Parallel batched inference: %d batches, max_workers=%d", len(batches), max_workers
    )

    batch_results: dict[int, list[str]] = {}
    if ctx is not None:
        max_workers = max(1, min(max_workers, ctx.budget.network_workers))
    else:
        max_workers = max(1, max_workers)
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_idx = {
            executor.submit(
                _run_sync_call_with_timeout,
                lambda batch=batch: provider.infer(batch, **infer_kwargs),
                timeout_s=ctx.opts.per_call_timeout_s if ctx is not None else None,
            ): idx
            for idx, batch in enumerate(batches)
        }
        if ctx is not None:
            ctx.budget.ensure_api_budget(len(batches))
        for future in concurrent.futures.as_completed(future_to_idx):
            idx = future_to_idx[future]
            if ctx is None:
                batch_result = normalize_text_outputs(
                    future.result(),
                    expected_count=len(batches[idx]),
                    context="provider.infer",
                )
            else:
                try:
                    batch_result = normalize_text_outputs(
                        future.result(),
                        expected_count=len(batches[idx]),
                        context="provider.infer",
                    )
                    _record_inference_success(ctx)
                except Exception as exc:
                    _maybe_handle_provider_error(ctx, exc)
                    raise
            batch_results[idx] = batch_result

    all_outputs: list[str] = []
    for idx in range(len(batches)):
        all_outputs.extend(batch_results[idx])
    return all_outputs


def _provider_supports_native_pdf(provider: Any) -> bool:
    """Check if provider explicitly advertises native PDF support."""
    kinds = getattr(provider, "supported_attachment_kinds", None)
    if kinds is not None:
        return "pdf" in kinds
    return False


def _provider_supports_async_text(provider: Any) -> bool:
    """Duck-type check for providers that expose async text inference."""
    return callable(getattr(provider, "ainfer", None))


def _ensure_native_pdf_support(
    provider: Any,
    attachments: Sequence[Any],
    *,
    media_options: Any,
    branch_label: str,
) -> None:
    """Fail fast when the caller explicitly requested native PDF input."""
    if media_options.pdf_mode != "native":
        return

    from .media.attachments import AttachmentKind

    if not any(attachment.kind is AttachmentKind.PDF for attachment in attachments):
        return
    if _provider_supports_native_pdf(provider):
        return

    provider_name = type(provider).__name__
    raise TypeError(
        f"Provider {provider_name} does not support native PDF input, but {branch_label} "
        "requested it. Use document_input='image', mode='page', media.pdf_mode='raster', "
        "or a provider that advertises PDF attachments."
    )


def _check_media_capability(provider: Any, *, is_async: bool = False) -> None:
    """Raise if provider doesn't support media inference.

    When *is_async* is False (sync path), only ``SupportsMediaInfer`` is
    accepted.  When True (async path), either protocol is fine.
    """
    if is_async:
        if not isinstance(
            provider,
            (
                SupportsMediaInfer,
                SupportsAsyncMediaInfer,
                SupportsMediaInferStream,
                SupportsAsyncMediaInferStream,
            ),
        ):
            provider_name = type(provider).__name__
            raise TypeError(
                f"Provider {provider_name} does not support media inference. "
                f"Use a provider that implements one of the media inference "
                f"protocols (sync, async, or streaming)."
            )
    else:
        if not isinstance(provider, (SupportsMediaInfer, SupportsMediaInferStream)):
            provider_name = type(provider).__name__
            if isinstance(provider, (SupportsAsyncMediaInfer, SupportsAsyncMediaInferStream)):
                raise TypeError(
                    f"Provider {provider_name} only supports async media inference "
                    f"(ainfer_media). Use aextract() / aextract_stream() for async "
                    f"extraction, or implement a sync media inference method on "
                    f"the provider."
                )
            raise TypeError(
                f"Provider {provider_name} does not support media inference. "
                f"Use a provider that implements SupportsMediaInfer (e.g. "
                f"PydanticAIProvider with a vision-capable model like "
                f"openai:gpt-4o or gemini:gemini-2.5-flash)."
            )


def _build_media_inference_requests(
    ctx: _ExtractionContext,
    doc: Document,
    media_chunks: list[MediaChunk],
) -> list[InferenceRequest]:
    """Build InferenceRequest objects for media chunks."""
    requests: list[InferenceRequest] = []
    for chunk in media_chunks:
        prompt_text = _render_prompt(
            ctx.prompt_obj,
            schema_text=ctx.schema_text,
            examples=ctx.normalized_examples,
            question=_media_chunk_question(doc, chunk),
            format_handler=ctx.format_handler,
            additional_context=doc.additional_context,
            output_kind=ctx.output_kind,
            native_mode=ctx.use_native_schema,
        )
        requests.append(
            InferenceRequest(
                prompt=prompt_text,
                attachments=(chunk.attachment,),
                document_id=doc.document_id,
                attachment_index=chunk.attachment_index,
                page_index=(chunk.page_index + 1) if chunk.page_index is not None else None,
            )
        )
    return requests


def _infer_media_batch(
    ctx_or_provider: _ExtractionContext | Any,
    provider_or_requests: Any = None,
    requests_or_batch_length: Sequence[InferenceRequest] | int | None = None,
    batch_length: int | None = None,
    **infer_kwargs: Any,
) -> list[str]:
    """Call provider.infer_media in batches."""
    if isinstance(ctx_or_provider, _ExtractionContext):
        ctx = ctx_or_provider
        provider = provider_or_requests
        requests = requests_or_batch_length
        batch_length = batch_length if batch_length is not None else 1
    else:
        ctx = None
        provider = ctx_or_provider
        requests = provider_or_requests
        batch_length = (
            requests_or_batch_length
            if isinstance(requests_or_batch_length, int)
            else batch_length
            if batch_length is not None
            else 1
        )
    logger.debug(
        "Media batched inference: %d requests, batch_length=%d", len(requests), batch_length
    )
    all_outputs: list[str] = []
    for i in range(0, len(requests), batch_length):
        batch = requests[i : i + batch_length]
        if ctx is None:
            outputs = normalize_text_outputs(
                provider.infer_media(batch, **infer_kwargs),
                expected_count=len(batch),
                context="provider.infer_media",
            )
        else:
            _check_document_deadline(ctx)
            ctx.budget.ensure_api_budget(1)
            try:
                raw = (
                    provider.infer_media(batch, **infer_kwargs)
                    if _should_bypass_sync_thread_timeout(provider, media=True)
                    else _run_sync_call_with_timeout(
                        lambda batch=batch: provider.infer_media(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                )
                outputs = normalize_text_outputs(
                    raw,
                    expected_count=len(batch),
                    context="provider.infer_media",
                )
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise
        all_outputs.extend(outputs)
    return all_outputs


async def _ainfer_media_batch(
    ctx_or_provider: _ExtractionContext | Any,
    provider_or_requests: Any = None,
    requests_or_batch_length: Sequence[InferenceRequest] | int | None = None,
    batch_length: int | None = None,
    **infer_kwargs: Any,
) -> list[str]:
    """Async version of media batched inference."""
    if isinstance(ctx_or_provider, _ExtractionContext):
        ctx = ctx_or_provider
        provider = provider_or_requests
        requests = requests_or_batch_length
        batch_length = batch_length if batch_length is not None else 1
    else:
        ctx = None
        provider = ctx_or_provider
        requests = provider_or_requests
        batch_length = (
            requests_or_batch_length
            if isinstance(requests_or_batch_length, int)
            else batch_length
            if batch_length is not None
            else 1
        )
    all_outputs: list[str] = []
    for i in range(0, len(requests), batch_length):
        batch = requests[i : i + batch_length]
        if ctx is None:
            if isinstance(provider, SupportsAsyncMediaInfer):
                outputs = await provider.ainfer_media(batch, **infer_kwargs)
            else:
                outputs = await asyncio.to_thread(provider.infer_media, batch, **infer_kwargs)
            outputs = normalize_text_outputs(
                outputs,
                expected_count=len(batch),
                context="provider.ainfer_media"
                if isinstance(provider, SupportsAsyncMediaInfer)
                else "provider.infer_media",
            )
        else:
            _check_document_deadline(ctx)
            ctx.budget.ensure_api_budget(1)
            try:
                if isinstance(provider, SupportsAsyncMediaInfer):
                    outputs = await _run_async_call_with_timeout(
                        lambda batch=batch: provider.ainfer_media(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                else:
                    outputs = await asyncio.to_thread(
                        lambda batch=batch: _run_sync_call_with_timeout(
                            lambda: provider.infer_media(batch, **infer_kwargs),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        )
                    )
                outputs = normalize_text_outputs(
                    outputs,
                    expected_count=len(batch),
                    context="provider.ainfer_media"
                    if isinstance(provider, SupportsAsyncMediaInfer)
                    else "provider.infer_media",
                )
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise
        all_outputs.extend(outputs)
    return all_outputs


def _infer_media_batch_parallel(
    ctx: _ExtractionContext,
    provider: Any,
    requests: Sequence[InferenceRequest],
    batch_length: int,
    max_workers: int,
    **infer_kwargs: Any,
) -> list[str]:
    """Call provider.infer_media in batches with ThreadPoolExecutor."""
    batch_length = max(1, batch_length)
    batches: list[Sequence[InferenceRequest]] = []
    for i in range(0, len(requests), batch_length):
        batches.append(requests[i : i + batch_length])
    max_workers = max(1, min(max_workers, ctx.budget.network_workers))
    logger.debug("Parallel media inference: %d batches, max_workers=%d", len(batches), max_workers)
    batch_results: dict[int, list[str]] = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_idx = {
            executor.submit(
                _run_sync_call_with_timeout,
                lambda batch=batch: provider.infer_media(batch, **infer_kwargs),
                timeout_s=ctx.opts.per_call_timeout_s,
            ): idx
            for idx, batch in enumerate(batches)
        }
        ctx.budget.ensure_api_budget(len(batches))
        for future in concurrent.futures.as_completed(future_to_idx):
            idx = future_to_idx[future]
            try:
                batch_results[idx] = normalize_text_outputs(
                    future.result(),
                    expected_count=len(batches[idx]),
                    context="provider.infer_media",
                )
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise
    all_outputs: list[str] = []
    for idx in range(len(batches)):
        all_outputs.extend(batch_results[idx])
    return all_outputs


async def _ainfer_media_batch_parallel(
    ctx: _ExtractionContext,
    provider: Any,
    requests: Sequence[InferenceRequest],
    batch_length: int,
    max_workers: int,
    **infer_kwargs: Any,
) -> list[str]:
    """Async parallel media inference with semaphore."""
    batch_length = max(1, batch_length)
    batches: list[Sequence[InferenceRequest]] = []
    for i in range(0, len(requests), batch_length):
        batches.append(requests[i : i + batch_length])
    max_workers = max(1, min(max_workers, ctx.budget.network_workers))
    sem = asyncio.Semaphore(max_workers)

    async def _run_batch(batch: Sequence[InferenceRequest]) -> list[str]:
        async with sem:
            _check_document_deadline(ctx)
            try:
                if isinstance(provider, SupportsAsyncMediaInfer):
                    raw = await _run_async_call_with_timeout(
                        lambda: provider.ainfer_media(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                else:
                    raw = await asyncio.to_thread(
                        lambda: _run_sync_call_with_timeout(
                            lambda: provider.infer_media(batch, **infer_kwargs),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        )
                    )
                normalized = normalize_text_outputs(
                    raw,
                    expected_count=len(batch),
                    context="provider.ainfer_media"
                    if isinstance(provider, SupportsAsyncMediaInfer)
                    else "provider.infer_media",
                )
                _record_inference_success(ctx)
                return normalized
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise

    ctx.budget.ensure_api_budget(len(batches))
    results = await asyncio.gather(*[_run_batch(b) for b in batches])
    all_outputs: list[str] = []
    for batch_result in results:
        all_outputs.extend(batch_result)
    return all_outputs


def _run_parallel_pair[T, U](
    first: Callable[[], T],
    second: Callable[[], U],
) -> tuple[T, U]:
    """Run two independent sync callables concurrently and preserve result order."""
    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first_future = executor.submit(first)
        second_future = executor.submit(second)
        return first_future.result(), second_future.result()


async def _arun_parallel_pair[T, U](
    first: Callable[[], Awaitable[T]],
    second: Callable[[], Awaitable[U]],
) -> tuple[T, U]:
    """Run two independent async callables concurrently and preserve result order."""
    return await asyncio.gather(first(), second())


def _build_single_inference_request(
    ctx: _ExtractionContext,
    doc: Document,
    media_chunks: list[MediaChunk],
) -> InferenceRequest:
    """Build a single InferenceRequest bundling all media chunks."""
    all_attachments = tuple(chunk.attachment for chunk in media_chunks)
    page_local_texts = [
        chunk.text for chunk in media_chunks if chunk.text and chunk.text != doc.text
    ]
    if page_local_texts:
        question = (
            _caller_hints_text(doc) + "\n\nPage-local text:\n" + "\n\n".join(page_local_texts)
        )
    else:
        question = _caller_hints_text(doc)
    prompt_text = _render_prompt(
        ctx.prompt_obj,
        schema_text=ctx.schema_text,
        examples=ctx.normalized_examples,
        question=question,
        format_handler=ctx.format_handler,
        additional_context=doc.additional_context,
        output_kind=ctx.output_kind,
        native_mode=ctx.use_native_schema,
    )
    return InferenceRequest(
        prompt=prompt_text,
        attachments=all_attachments,
        document_id=doc.document_id,
    )


def _resolve_page_strategy(
    strategy: str,
    media_chunks: list[MediaChunk],
    *,
    preflights: dict[int, PreflightResult] | None = None,
) -> str:
    """Resolve 'auto' page_strategy to concrete strategy.

    For native PDFs with a usable text layer, ``"single"`` sends the whole
    document in one request (faster).  For scanned / image-only PDFs, we
    switch to ``"map_reduce"`` so each page is processed separately and we
    get per-page provenance.
    """
    if strategy != "auto":
        return strategy
    if not media_chunks:
        return "map_reduce"
    from .media.attachments import AttachmentKind

    all_native_pdf = all(chunk.attachment.kind is AttachmentKind.PDF for chunk in media_chunks)
    if not all_native_pdf:
        return "map_reduce"

    # Note: for scanned PDFs, "single" is still preferred for flat-schema
    # extraction since per-page extraction would fragment the data.
    # Provenance for scanned PDFs is handled by bbox enrichment when a
    # text layer is available, or left at document scope otherwise.

    return "single"


@dataclass(frozen=True, slots=True)
class _PdfPageText:
    attachment_index: int
    page_index: int
    text: str


def _extract_pdf_page_texts(doc: Document) -> list[_PdfPageText]:
    if not doc.attachments:
        return []
    from .media.attachments import AttachmentKind

    if any(attachment.kind is not AttachmentKind.PDF for attachment in doc.attachments):
        return []

    try:
        from .media.preprocessing import extract_pdf_page_texts, has_text_layer

        if not any(has_text_layer(attachment.source) for attachment in doc.attachments):
            return []

        texts: list[_PdfPageText] = []
        for attachment_index, attachment in enumerate(doc.attachments):
            for page_index, page_text in extract_pdf_page_texts(
                attachment.source,
                page_indices=attachment.page_indices,
            ):
                if page_text:
                    texts.append(
                        _PdfPageText(
                            attachment_index=attachment_index,
                            page_index=page_index + 1,
                            text=page_text,
                        )
                    )
        return texts
    except ImportError:
        return []
    except (OSError, ValueError, RuntimeError):
        logger.debug("PDF text extraction failed, falling back to media path", exc_info=True)
        return []


_MIN_TEXT_CHARS_WITH_IMAGES = 50  # min avg chars when PDF pages also have images


def _try_pdf_text_extraction(doc: Document, media_options: Any) -> Document | None:
    """If pdf_mode='auto' and PDF has text layer, return a text-only Document.

    When PDF pages contain embedded images *and* the text layer is very short
    the text is likely just watermarks or page labels.  In that case we return
    ``None`` so the caller falls back to the media/vision path.
    """
    if media_options.pdf_mode != "auto":
        return None
    page_texts = _extract_pdf_page_texts(doc)
    if not page_texts:
        return None

    # If any PDF page contains embedded images and has very short text,
    # the "text layer" is probably just watermarks/page-labels on those pages.
    # Fall back to the media/vision path so the model can read the images.
    if doc.attachments:
        try:
            from .media.preprocessing import prepare_pdf

            for att in doc.attachments:
                prepared = prepare_pdf(att.source, page_indices=att.page_indices)
                for p in prepared.pages:
                    if p.has_images and len(p.text.strip()) < _MIN_TEXT_CHARS_WITH_IMAGES:
                        logger.debug(
                            "Page %d has images and only %d chars of text; "
                            "falling back to media path",
                            p.page_index,
                            len(p.text.strip()),
                        )
                        return None
        except ImportError:
            pass

    page_parts: list[str] = []
    page_spans: list[DocumentPageSpan] = []
    offset = 0
    if doc.text:
        offset = len(doc.text)
    for page in page_texts:
        if doc.text or page_parts:
            offset += 2
        start = offset
        page_parts.append(page.text)
        offset += len(page.text)
        page_spans.append(
            DocumentPageSpan(
                attachment_index=page.attachment_index,
                page_index=page.page_index,
                start=start,
                end=offset,
            )
        )
    extracted = "\n\n".join(page_parts)
    combined = f"{doc.text}\n\n{extracted}" if doc.text else extracted
    return Document(
        text=combined,
        document_id=doc.document_id,
        additional_context=doc.additional_context,
        attachments=(),
        page_spans=tuple(page_spans),
    )


def _estimate_media_request_count(
    *,
    plan: str,
    media_chunks: Sequence[MediaChunk],
) -> int:
    if plan in {"whole_document", "single"}:
        return 1 if media_chunks else 0
    return len(media_chunks)


def _remaining_api_budget(ctx: _ExtractionContext) -> int | None:
    if ctx.opts.max_api_calls is None:
        return None
    return ctx.opts.max_api_calls - ctx.budget.api_calls_used


def _extract_preflight(doc: Document) -> dict[int, PreflightResult]:
    if not doc.attachments:
        return {}
    from .media.attachments import AttachmentKind

    preflights: dict[int, PreflightResult] = {}
    for attachment_index, attachment in enumerate(doc.attachments):
        if attachment.kind is not AttachmentKind.PDF:
            continue
        try:
            preflights[attachment_index] = analyze_pdf_source(
                attachment.source, page_indices=attachment.page_indices
            )
        except Exception:
            logger.debug("PDF preflight failed; continuing without preflight data", exc_info=True)
    return preflights


def _preflight_mode_by_chunk(
    doc: Document,
    media_chunks: Sequence[MediaChunk],
) -> dict[tuple[int | None, int | None], Literal["text_only", "image_only", "fused"]]:
    preflights = _extract_preflight(doc)
    mode_by_chunk: dict[
        tuple[int | None, int | None], Literal["text_only", "image_only", "fused"]
    ] = {}
    for attachment_index, preflight in preflights.items():
        for page in preflight.pages:
            mode_by_chunk[(attachment_index, page.page_index)] = page.recommended_mode
    return mode_by_chunk


def _resolve_fused_media_chunks(
    doc: Document,
    media_chunks: list[MediaChunk],
) -> list[MediaChunk]:
    mode_by_chunk = _preflight_mode_by_chunk(doc, media_chunks)
    resolved: list[MediaChunk] = []
    for chunk in media_chunks:
        if chunk.page_index is None:
            resolved.append(chunk)
            continue
        mode = mode_by_chunk.get((chunk.attachment_index, chunk.page_index), chunk.mode)
        resolved.append(replace(chunk, mode=mode))
    return resolved


def _enforce_document_limits(
    ctx: _ExtractionContext,
    doc: Document,
) -> dict[int, PreflightResult]:
    preflights = _extract_preflight(doc)
    if ctx.opts.max_pdf_bytes is not None:
        total_pdf_bytes = 0
        for attachment in doc.attachments:
            source = attachment.source
            if isinstance(source, bytes):
                total_pdf_bytes += len(source)
            else:
                total_pdf_bytes += source.stat().st_size
        if total_pdf_bytes > ctx.opts.max_pdf_bytes:
            raise RuntimeError(
                f"PDF size limit exceeded: {total_pdf_bytes} > {ctx.opts.max_pdf_bytes}"
            )
    if ctx.opts.max_pages is not None:
        total_pages = sum(preflight.page_count for preflight in preflights)
        if total_pages > ctx.opts.max_pages:
            raise RuntimeError(f"Page limit exceeded: {total_pages} > {ctx.opts.max_pages}")
    return preflights


def _parse_with_repair[T](
    raw: str,
    target: type[T] | TypeAdapter[T],
    *,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    repair: str,
    allow_partial: bool = False,
) -> ParseResult[T]:
    """Parse raw output, optionally applying local repair on validation failure."""
    try:
        return parse(
            raw,
            target,
            parse_options=parse_options,
            coerce_options=coerce_options,
            is_done=True,
            allow_partial=allow_partial,
        )
    except ValidationError:
        if repair != "local":
            raise
        # Local repair: retry with relaxed coercion (enable substring enum matching)
        relaxed = replace(coerce_options or CoerceOptions(), allow_substring_enum_match=True)
        return parse(
            raw,
            target,
            parse_options=parse_options,
            coerce_options=relaxed,
            is_done=True,
            allow_partial=allow_partial,
        )


def _effective_repair_mode(opts: ExtractOptions) -> Literal["none", "local", "targeted"]:
    if opts.repair == "targeted":
        return "targeted"
    if opts.passes > 1:
        return "targeted"
    return opts.repair


def _validation_errors_for_state(
    ctx: _ExtractionContext,
    state: _DocumentState,
) -> list[str]:
    try:
        ctx.adapter.validate(state.merged_value)
    except ValidationError as exc:
        return collect_validation_errors(exc)
    except ValueError as exc:
        return [str(exc)]
    return []


def _validation_error_paths_for_state(
    ctx: _ExtractionContext,
    state: _DocumentState,
) -> list[str]:
    try:
        ctx.adapter.validate(state.merged_value)
    except ValidationError as exc:
        return validation_error_paths(exc) or ["/"]
    except ValueError:
        return ["/"]
    return []


def _slice_repair_value(value: Any, paths: Sequence[str]) -> Any:
    if not paths or paths == ["/"] or not isinstance(value, dict):
        return value
    return slice_doc_for_paths(value, list(paths))


def _pointer_value_or_missing(value: Any, path: str) -> Any:
    if path in {"", "/"}:
        return value
    current = value
    for token in _pointer_tokens(path):
        if isinstance(current, dict):
            if token not in current:
                return _MISSING
            current = current[token]
        elif isinstance(current, list):
            try:
                index = int(token)
            except ValueError:
                return _MISSING
            if index < 0 or index >= len(current):
                return _MISSING
            current = current[index]
        else:
            return _MISSING
    return current


def _set_pointer_value(value: Any, path: str, new_value: Any) -> Any:
    if path in {"", "/"}:
        return new_value
    tokens = list(_pointer_tokens(path))
    if isinstance(value, dict):
        updated = dict(value)
    elif isinstance(value, list):
        updated = list(value)
    else:
        updated = {}
    current = updated
    for idx, token in enumerate(tokens[:-1]):
        next_token = tokens[idx + 1]
        if isinstance(current, dict):
            child = current.get(token, [] if next_token.isdigit() else {})
            if isinstance(child, dict):
                child = dict(child)
            elif isinstance(child, list):
                child = list(child)
            else:
                child = [] if next_token.isdigit() else {}
            current[token] = child
            current = child
        else:
            token_index = int(token)
            while len(current) <= token_index:
                current.append([] if next_token.isdigit() else {})
            child = current[token_index]
            if isinstance(child, dict):
                child = dict(child)
            elif isinstance(child, list):
                child = list(child)
            else:
                child = [] if next_token.isdigit() else {}
            current[token_index] = child
            current = child

    last = tokens[-1]
    if isinstance(current, dict):
        current[last] = new_value
    else:
        last_index = int(last)
        while len(current) <= last_index:
            current.append(None)
        current[last_index] = new_value
    return updated


def _apply_targeted_fragment(
    base_value: Any,
    fragment_value: Any,
    target_paths: Sequence[str],
) -> Any:
    if not target_paths or target_paths == ["/"]:
        return fragment_value
    merged_value = base_value
    for path in target_paths:
        patched_value = _pointer_value_or_missing(fragment_value, path)
        if patched_value is _MISSING:
            continue
        merged_value = _set_pointer_value(merged_value, path, patched_value)
    return merged_value


def _replace_evidence_for_paths(
    existing: Sequence[FieldEvidence],
    incoming: Sequence[FieldEvidence],
    target_paths: Sequence[str],
) -> list[FieldEvidence]:
    if not target_paths:
        return _dedupe_field_evidence([*existing, *incoming])
    filtered_existing = [
        item
        for item in existing
        if not any(_paths_overlap(item.path, {_pointer_path(path)}) for path in target_paths)
    ]
    return _dedupe_field_evidence([*filtered_existing, *incoming])


def _build_targeted_repair_prompt(
    *,
    ctx: _ExtractionContext,
    doc: Document,
    state: _DocumentState,
    original_prompt: str,
) -> tuple[str, list[str]]:
    errors = _validation_errors_for_state(ctx, state)
    target_paths = _validation_error_paths_for_state(ctx, state)
    repair_value = _slice_repair_value(state.merged_value, target_paths)
    return build_repair_prompt(
        original_question=original_prompt or _caller_hints_text(doc),
        additional_context=doc.additional_context,
        schema_text=(
            slice_schema_for_paths(ctx.schema_text, target_paths)
            if ctx.schema_text and target_paths and target_paths != ["/"]
            else ctx.schema_text
        ),
        repair_context=RepairContext(
            attempt_index=max(len(state.raw_outputs) - 1, 0),
            prior_output=format_broken_output(repair_value),
            validation_errors=errors,
            target_paths=target_paths,
        ),
    ), target_paths


def _default_merged_value(output_kind: _RootKind | None) -> Any:
    if output_kind == "array":
        return []
    if output_kind == "object":
        return {}
    return None


def _is_unusable_partial(
    parsed: ParseResult[Any],
    output_kind: _RootKind | None,
) -> bool:
    if output_kind not in {"object", "array"}:
        return False
    return "partial_unvalidated" in parsed.flags


def _prepare_document_chunks_and_prompts(
    ctx: _ExtractionContext,
    doc: Document,
) -> tuple[list[TextChunk], list[str]]:
    chunks = list(
        iter_chunks(
            doc.text,
            max_char_buffer=ctx.opts.max_char_buffer,
            tokenizer=ctx.opts.tokenizer,
            overlap_chars=ctx.opts.overlap_chars,
        )
    )
    chunk_prompts = [
        _render_prompt(
            ctx.prompt_obj,
            schema_text=ctx.schema_text,
            examples=ctx.normalized_examples,
            question=chunk.text,
            format_handler=ctx.format_handler,
            additional_context=doc.additional_context,
            output_kind=ctx.output_kind,
            native_mode=ctx.use_native_schema,
        )
        for chunk in chunks
    ]
    return chunks, chunk_prompts


def _build_single_text_prompt(
    ctx: _ExtractionContext,
    doc: Document,
) -> str:
    question = doc.text or "Extract structured data from this document."
    return _render_prompt(
        ctx.prompt_obj,
        schema_text=ctx.schema_text,
        examples=ctx.normalized_examples,
        question=question,
        format_handler=ctx.format_handler,
        additional_context=doc.additional_context,
        output_kind=ctx.output_kind,
        native_mode=ctx.use_native_schema,
    )


def _build_sources(doc_evidence: Sequence[FieldEvidence]) -> dict[str, SourceRef]:
    pages_by_path: dict[str, set[int]] = {}
    document_only_paths: set[str] = set()

    for evidence in doc_evidence:
        if evidence.page_index is None:
            document_only_paths.add(evidence.path)
            continue
        pages_by_path.setdefault(evidence.path, set()).add(evidence.page_index)

    sources: dict[str, SourceRef] = {}
    for path in sorted(document_only_paths | set(pages_by_path)):
        pages = tuple(sorted(pages_by_path.get(path, ())))
        if pages:
            sources[path] = SourceRef(scope="page", pages=pages)
        else:
            sources[path] = SourceRef(scope="document")
    return sources


def _assign_page_span(
    evidence: FieldEvidence,
    page_spans: Sequence[DocumentPageSpan],
) -> FieldEvidence:
    if evidence.page_index is not None or evidence.char_interval is None or not page_spans:
        return evidence
    start, end = evidence.char_interval
    for span in page_spans:
        if start >= span.start and end <= span.end:
            return replace(
                evidence,
                attachment_index=span.attachment_index,
                page_index=span.page_index,
            )
    for span in page_spans:
        if start < span.end and end > span.start:
            return replace(
                evidence,
                attachment_index=span.attachment_index,
                page_index=span.page_index,
            )
    return evidence


def _ground_document_evidence(
    doc: Document,
    evidence: Sequence[FieldEvidence],
) -> list[FieldEvidence]:
    if not doc.page_spans:
        return list(evidence)
    return [_assign_page_span(item, doc.page_spans) for item in evidence]


def _support_for_evidence(path_evidence: Sequence[FieldEvidence]) -> tuple[SupportStatus, float]:
    if not path_evidence:
        return (SupportStatus.UNSUPPORTED, 0.0)
    if any(item.alignment_status == AlignmentStatus.MATCH_EXACT for item in path_evidence):
        return (SupportStatus.EXACT, 1.0)
    if any(item.alignment_status == AlignmentStatus.MATCH_FUZZY for item in path_evidence):
        return (SupportStatus.FUZZY, 0.8)
    if any(
        item.alignment_status == AlignmentStatus.MATCH_LESSER or item.source == "vision"
        for item in path_evidence
    ):
        return (SupportStatus.INFERRED, 0.6)
    return (SupportStatus.UNSUPPORTED, 0.0)


def _build_field_statuses(
    merged_value: Any,
    doc_evidence: Sequence[FieldEvidence],
    diagnostics: dict[str, FieldDiagnostic],
) -> list[FieldStatus]:
    evidence_by_path: dict[str, list[FieldEvidence]] = defaultdict(list)
    for item in doc_evidence:
        evidence_by_path[_pointer_path(item.path)].append(item)

    statuses: list[FieldStatus] = []
    for path, raw_value in _iter_leaf_values(merged_value):
        pointer_path = _pointer_path(path)
        support, confidence = _support_for_evidence(evidence_by_path.get(pointer_path, ()))
        diagnostic = diagnostics.get(pointer_path)
        if support == SupportStatus.UNSUPPORTED and diagnostic is not None:
            confidence = diagnostic.confidence
        if raw_value == "":
            confidence = min(confidence, 0.5)
        statuses.append(
            FieldStatus(
                path=pointer_path,
                support=support,
                confidence=confidence,
            )
        )
    return statuses


def _caller_hints_text(doc: Document) -> str:
    return doc.text or "Extract structured data from this document."


def _media_chunk_question(doc: Document, chunk: MediaChunk) -> str:
    if chunk.text and doc.text:
        return f"{doc.text}\n\nPage-local text:\n{chunk.text}"
    return chunk.text or doc.text or "Extract structured data from this document."


def _repair_attempt_limit(ctx: _ExtractionContext) -> int:
    legacy_attempts = max(ctx.opts.passes - 1, 0)
    configured_attempts = ctx.opts.max_repair_attempts
    if ctx.opts.repair == "targeted":
        return max(configured_attempts, legacy_attempts)
    if legacy_attempts:
        return legacy_attempts
    return 0


def _should_attempt_targeted_repair(ctx: _ExtractionContext) -> bool:
    return ctx.opts.repair == "targeted" or ctx.opts.passes > 1


def _repair_prompt_for_document(
    *,
    ctx: _ExtractionContext,
    doc: Document,
    repair_context: RepairContext,
) -> str:
    return build_repair_prompt(
        original_question=_caller_hints_text(doc),
        additional_context=doc.additional_context,
        schema_text=ctx.schema_text,
        repair_context=repair_context,
    )


def _finalize_result_with_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    state: _DocumentState,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    rendered_prompt_preview: str | None,
    media_attachments: tuple[Any, ...] = (),
) -> ExtractResult[T]:
    merged_value = state.merged_value
    diagnostics = state.diagnostics
    try:
        ctx.adapter.validate(merged_value)
    except ValidationError as error:
        if not _should_attempt_targeted_repair(ctx) or _repair_attempt_limit(ctx) <= 0:
            raise
        broken_output = format_broken_output(merged_value)
        validation_errors = collect_validation_errors(error)
        for attempt_index in range(_repair_attempt_limit(ctx)):
            repair_prompt = _repair_prompt_for_document(
                ctx=ctx,
                doc=doc,
                repair_context=RepairContext(
                    attempt_index=attempt_index,
                    prior_output=broken_output,
                    validation_errors=validation_errors,
                ),
            )
            try:
                _check_document_deadline(ctx)
                ctx.budget.ensure_api_budget(1)
                if media_attachments:
                    raw = normalize_text_outputs(
                        _run_sync_call_with_timeout(
                            lambda repair_prompt=repair_prompt: ctx.provider.infer_media(
                                [
                                    InferenceRequest(
                                        prompt=repair_prompt,
                                        attachments=media_attachments,
                                        document_id=doc.document_id,
                                    )
                                ]
                            ),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        ),
                        expected_count=1,
                        context="provider.infer_media",
                    )[0]
                else:
                    raw = normalize_text_outputs(
                        _run_sync_call_with_timeout(
                            lambda repair_prompt=repair_prompt: ctx.provider.infer([repair_prompt]),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        ),
                        expected_count=1,
                        context="provider.infer",
                    )[0]
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise

            state.raw_outputs.append(raw)
            parsed = _parse_with_repair(
                raw,
                target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                repair="local" if ctx.opts.repair in {"local", "targeted"} else "none",
                allow_partial=False,
            )
            merged_value = to_jsonable_python(parsed.value)
            diagnostics = _build_field_diagnostics(
                merged_value, state.doc_evidence, source="repair"
            )
            state.all_flags.update(parsed.flags)
            state.worst_score = max(state.worst_score, parsed.score)
            try:
                ctx.adapter.validate(merged_value)
                break
            except ValidationError as next_error:
                broken_output = format_broken_output(merged_value)
                validation_errors = collect_validation_errors(next_error)
        else:
            raise ValidationError.from_exception_data("repair", error.errors())

    return _build_extract_result(
        ctx=ctx,
        doc=doc,
        merged_value=merged_value,
        raw_outputs=state.raw_outputs,
        all_flags=state.all_flags,
        worst_score=state.worst_score,
        doc_evidence=state.doc_evidence,
        chunk_debug_entries=state.chunk_debug_entries,
        rendered_prompt_preview=rendered_prompt_preview,
        debug=debug,
        conflicts=state.conflicts,
        diagnostics=diagnostics,
    )


async def _afinalize_result_with_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    state: _DocumentState,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    rendered_prompt_preview: str | None,
    media_attachments: tuple[Any, ...] = (),
) -> ExtractResult[T]:
    merged_value = state.merged_value
    diagnostics = state.diagnostics
    try:
        ctx.adapter.validate(merged_value)
    except ValidationError as error:
        if not _should_attempt_targeted_repair(ctx) or _repair_attempt_limit(ctx) <= 0:
            raise
        broken_output = format_broken_output(merged_value)
        validation_errors = collect_validation_errors(error)
        for attempt_index in range(_repair_attempt_limit(ctx)):
            repair_prompt = _repair_prompt_for_document(
                ctx=ctx,
                doc=doc,
                repair_context=RepairContext(
                    attempt_index=attempt_index,
                    prior_output=broken_output,
                    validation_errors=validation_errors,
                ),
            )
            try:
                _check_document_deadline(ctx)
                ctx.budget.ensure_api_budget(1)
                if media_attachments:
                    if isinstance(ctx.provider, SupportsAsyncMediaInfer):
                        raw_result = await _run_async_call_with_timeout(
                            lambda repair_prompt=repair_prompt: ctx.provider.ainfer_media(
                                [
                                    InferenceRequest(
                                        prompt=repair_prompt,
                                        attachments=media_attachments,
                                        document_id=doc.document_id,
                                    )
                                ]
                            ),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        )
                    else:
                        raw_result = await asyncio.to_thread(
                            lambda repair_prompt=repair_prompt: _run_sync_call_with_timeout(
                                lambda: ctx.provider.infer_media(
                                    [
                                        InferenceRequest(
                                            prompt=repair_prompt,
                                            attachments=media_attachments,
                                            document_id=doc.document_id,
                                        )
                                    ]
                                ),
                                timeout_s=ctx.opts.per_call_timeout_s,
                            )
                        )
                    raw = normalize_text_outputs(
                        raw_result,
                        expected_count=1,
                        context="provider.ainfer_media",
                    )[0]
                else:
                    if hasattr(ctx.provider, "ainfer"):
                        raw_result = await _run_async_call_with_timeout(
                            lambda repair_prompt=repair_prompt: ctx.provider.ainfer(
                                [repair_prompt]
                            ),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        )
                    else:
                        raw_result = await asyncio.to_thread(
                            lambda repair_prompt=repair_prompt: _run_sync_call_with_timeout(
                                lambda: ctx.provider.infer([repair_prompt]),
                                timeout_s=ctx.opts.per_call_timeout_s,
                            )
                        )
                    raw = normalize_text_outputs(
                        raw_result, expected_count=1, context="provider.ainfer"
                    )[0]
                _record_inference_success(ctx)
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise

            state.raw_outputs.append(raw)
            parsed = _parse_with_repair(
                raw,
                target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                repair="local" if ctx.opts.repair in {"local", "targeted"} else "none",
                allow_partial=False,
            )
            merged_value = to_jsonable_python(parsed.value)
            diagnostics = _build_field_diagnostics(
                merged_value, state.doc_evidence, source="repair"
            )
            state.all_flags.update(parsed.flags)
            state.worst_score = max(state.worst_score, parsed.score)
            try:
                ctx.adapter.validate(merged_value)
                break
            except ValidationError as next_error:
                broken_output = format_broken_output(merged_value)
                validation_errors = collect_validation_errors(next_error)
        else:
            raise ValidationError.from_exception_data("repair", error.errors())

    return _build_extract_result(
        ctx=ctx,
        doc=doc,
        merged_value=merged_value,
        raw_outputs=state.raw_outputs,
        all_flags=state.all_flags,
        worst_score=state.worst_score,
        doc_evidence=state.doc_evidence,
        chunk_debug_entries=state.chunk_debug_entries,
        rendered_prompt_preview=rendered_prompt_preview,
        debug=debug,
        conflicts=state.conflicts,
        diagnostics=diagnostics,
    )


def _enrich_evidence_with_bboxes(
    attachments: tuple[Any, ...],
    evidence: list[FieldEvidence],
) -> list[FieldEvidence]:
    """Add bounding boxes to evidence items from PDF text spans.

    *attachments* should be the original PDF attachments (possibly preserved
    from before text-layer extraction stripped them).

    For each evidence item that already has a page_index but no bbox_norm,
    searches the PDF text layer for a matching span and assigns coordinates.
    """
    if not attachments:
        return evidence

    from .media.attachments import AttachmentKind

    pdf_attachments = [att for att in attachments if att.kind is AttachmentKind.PDF]
    if not pdf_attachments:
        return evidence

    try:
        from .media.preprocessing import extract_pdf_text_spans, find_bbox_for_value
    except ImportError:
        return evidence

    # Build span index keyed by attachment
    spans_by_attachment: dict[int, list] = {}
    for att_idx, attachment in enumerate(attachments):
        if attachment.kind is not AttachmentKind.PDF:
            continue
        try:
            spans = extract_pdf_text_spans(attachment.source, page_indices=attachment.page_indices)
            spans_by_attachment[att_idx] = spans
        except Exception:
            logger.debug("Failed to extract PDF text spans for bbox enrichment", exc_info=True)

    if not spans_by_attachment:
        return evidence

    enriched: list[FieldEvidence] = []
    for ev in evidence:
        if ev.bbox_norm is not None or not ev.value_preview:
            enriched.append(ev)
            continue

        att_idx = ev.attachment_index if ev.attachment_index is not None else 0
        spans = spans_by_attachment.get(att_idx)
        if not spans:
            enriched.append(ev)
            continue

        # Convert 1-indexed page_index to 0-indexed for lookup
        page_0 = (ev.page_index - 1) if ev.page_index is not None else None
        result = find_bbox_for_value(spans, ev.value_preview, page_index=page_0)
        if result:
            found_page, bbox_norm = result
            enriched.append(
                FieldEvidence(
                    path=ev.path,
                    value_preview=ev.value_preview,
                    char_interval=ev.char_interval,
                    token_interval=ev.token_interval,
                    alignment_status=ev.alignment_status,
                    source=ev.source,
                    attachment_index=ev.attachment_index,
                    page_index=ev.page_index,
                    bbox_norm=bbox_norm,
                    grounding_method="ocr_align",
                )
            )
        else:
            enriched.append(ev)
    return enriched


def _build_extract_result[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    merged_value: Any,
    raw_outputs: list[str],
    all_flags: set[str],
    worst_score: int,
    doc_evidence: list[FieldEvidence],
    chunk_debug_entries: list[ChunkDebug],
    rendered_prompt_preview: str | None,
    debug: bool,
    conflicts: list[MergeConflict] | None = None,
    diagnostics: dict[str, FieldDiagnostic] | None = None,
) -> ExtractResult[T]:
    validated = ctx.adapter.validate(merged_value)
    grounded_evidence = _ground_document_evidence(doc, doc_evidence)
    # Use original PDF attachments from context (may have been stripped by text extraction)
    pdf_atts = ctx.original_pdf_attachments or doc.attachments
    grounded_evidence = _enrich_evidence_with_bboxes(pdf_atts, grounded_evidence)
    resolved_diagnostics = diagnostics or _build_field_diagnostics(merged_value, grounded_evidence)
    debug_info = (
        ExtractDebug(
            prompt=ctx.prompt_obj.description,
            raw_outputs=raw_outputs,
            chunks=chunk_debug_entries,
            rendered_prompt_preview=rendered_prompt_preview,
        )
        if debug
        else None
    )
    return ExtractResult(
        value=validated,
        document_id=doc.document_id,
        raw_text=raw_outputs[-1] if raw_outputs else None,
        flags=tuple(sorted(all_flags)),
        score=worst_score,
        evidence=grounded_evidence,
        field_statuses=_build_field_statuses(merged_value, grounded_evidence, resolved_diagnostics),
        sources=_build_sources(grounded_evidence),
        diagnostics=resolved_diagnostics,
        debug=debug_info,
        conflicts=conflicts or [],
    )


@dataclass(slots=True)
class _DocumentState:
    """Mutable accumulator for per-document extraction across passes."""

    merged_value: Any = None
    raw_outputs: list[str] = field(default_factory=list)
    all_flags: set[str] = field(default_factory=set)
    worst_score: int = 0
    chunk_debug_entries: list[ChunkDebug] = field(default_factory=list)
    doc_evidence: list[FieldEvidence] = field(default_factory=list)
    conflicts: list[MergeConflict] = field(default_factory=list)
    diagnostics: dict[str, FieldDiagnostic] = field(default_factory=dict)


def _has_evidence_at_path(path: str, evidence_paths: set[str]) -> bool:
    pointer_path = _pointer_path(path)
    return any(
        item == pointer_path or item.startswith(pointer_path + "/") for item in evidence_paths
    )


def _classify_field_state(value: Any, *, has_evidence: bool) -> FieldState:
    if value is None:
        return FieldState.ABSENT
    if isinstance(value, str) and value == "":
        return FieldState.EMPTY if has_evidence else FieldState.ABSENT
    if isinstance(value, (list, dict)) and not value:
        return FieldState.EMPTY if has_evidence else FieldState.ABSENT
    return FieldState.PRESENT


def _diagnostic_confidence(state: FieldState, *, has_evidence: bool) -> float:
    if state == FieldState.ABSENT:
        return 0.0
    if has_evidence:
        return 1.0
    if state == FieldState.EMPTY:
        return 0.5
    return 0.75


def _build_field_diagnostics(
    value: Any,
    evidence: Sequence[FieldEvidence],
    *,
    source: Literal["document", "page", "fused", "repair"] = "document",
) -> dict[str, FieldDiagnostic]:
    diagnostics: dict[str, FieldDiagnostic] = {}
    evidence_paths = {_pointer_path(item.path) for item in evidence}

    def _visit(current: Any, path: str) -> None:
        pointer_path = _pointer_path(path)
        has_evidence = _has_evidence_at_path(pointer_path, evidence_paths)
        state = _classify_field_state(current, has_evidence=has_evidence)
        diagnostics[pointer_path] = FieldDiagnostic(
            state=state,
            source=source,
            confidence=_diagnostic_confidence(state, has_evidence=has_evidence),
        )
        if isinstance(current, dict):
            for key, child in current.items():
                _visit(child, _pointer_with_child(path, key))
        elif isinstance(current, list):
            for idx, child in enumerate(current):
                _visit(child, _pointer_with_child(path, idx))

    if value is not None:
        _visit(value, "")
    return diagnostics


def _value_for_evidence(value: Any) -> Any:
    if hasattr(value, "model_dump"):
        return _value_for_evidence(value.model_dump(exclude_defaults=True))
    if isinstance(value, list):
        return [_value_for_evidence(item) for item in value]
    if isinstance(value, dict):
        return {key: _value_for_evidence(item) for key, item in value.items()}
    return to_jsonable_python(value)


def _accumulate_pass[T](
    *,
    pass_index: int,
    state: _DocumentState,
    chunks: Sequence[TextChunk],
    inferred: list[str],
    target: type[T] | TypeAdapter[T],
    ctx: _ExtractionContext,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
) -> None:
    """Process one inference pass and accumulate into *state*.

    May raise if ``chunk_error="raise"`` — this preserves fail-fast semantics
    so that subsequent passes are never invoked after a failure.
    """
    logger.debug("Extract pass %d: processing %d chunks", pass_index, len(chunks))
    pass_value: Any = None
    pass_evidence: list[FieldEvidence] = []
    pass_conflicts: list[MergeConflict] = []
    for chunk_idx, (chunk, raw) in enumerate(zip(chunks, inferred, strict=True)):
        if not raw:
            if debug:
                state.chunk_debug_entries.append(
                    ChunkDebug(
                        chunk_index=chunk_idx,
                        chunk_text_preview=chunk.text[:100],
                        raw_output="",
                        flags=(),
                        score=0,
                        error="empty output",
                    )
                )
            continue

        state.raw_outputs.append(raw)
        parse_error: str | None = None
        parsed: ParseResult[T] | None = None
        chunk_value: Any | None = None
        chunk_evidence: list[FieldEvidence] = []

        try:
            parsed = _parse_with_repair(
                raw,
                target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                repair=ctx.opts.repair,
                allow_partial=True,
            )
            if _is_unusable_partial(parsed, ctx.output_kind):
                raise ValueError(
                    "Chunk parse produced unvalidated partial output "
                    "that does not match the target root kind"
                )
            chunk_value = to_jsonable_python(parsed.value)
            state.all_flags.update(parsed.flags)
            state.worst_score = max(state.worst_score, parsed.score)
            chunk_evidence = _align_evidence(
                chunk.text,
                _value_for_evidence(parsed.value),
                tokenizer=ctx.opts.tokenizer,
                alignment=ctx.opts.alignment,
                offset=chunk.start,
            )
        except (ValidationError, ValueError, TypeError) as exc:
            logger.debug("Chunk parse failed: %s", exc)
            parse_error = str(exc)
            if ctx.opts.chunk_error == "raise":
                raise

        if chunk_value is not None:
            pass_value, leaf_map = _merge_branch_values(
                pass_value,
                chunk_value,
                strategy=ctx.opts.merge_strategy,
                conflicts=pass_conflicts,
                path="/",
            )
            pass_evidence.extend(_remap_leaf_evidence(chunk_evidence, leaf_map))

        if debug:
            state.chunk_debug_entries.append(
                ChunkDebug(
                    chunk_index=chunk_idx,
                    chunk_text_preview=chunk.text[:100],
                    raw_output=raw,
                    flags=parsed.flags if parsed is not None else (),
                    score=parsed.score if parsed is not None else 0,
                    error=parse_error,
                )
            )

    if pass_value is None:
        pass_value = _default_merged_value(ctx.output_kind)
    state.merged_value = _merge_values(
        state.merged_value,
        pass_value,
        strategy=ctx.opts.merge_strategy,
        conflicts=state.conflicts,
    )
    state.conflicts.extend(pass_conflicts)
    if state.merged_value is None:
        state.merged_value = _default_merged_value(ctx.output_kind)
    if pass_index == 0:
        state.doc_evidence = pass_evidence
    else:
        state.doc_evidence = _dedupe_field_evidence([*state.doc_evidence, *pass_evidence])
    state.diagnostics = _build_field_diagnostics(state.merged_value, state.doc_evidence)


def _accumulate_media_pass[T](
    *,
    pass_index: int,
    state: _DocumentState,
    media_requests: list[InferenceRequest],
    media_chunks: list[MediaChunk],
    inferred: list[str],
    target: type[T] | TypeAdapter[T],
    ctx: _ExtractionContext,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
) -> None:
    """Process one media inference pass — parse outputs, skip text alignment,
    and produce vision-sourced FieldEvidence instead."""
    pass_value: Any = None
    pass_evidence: list[FieldEvidence] = []
    pass_conflicts: list[MergeConflict] = []
    for req_idx, (req, chunk, raw) in enumerate(
        zip(media_requests, media_chunks, inferred, strict=True)
    ):
        if not raw:
            continue

        state.raw_outputs.append(raw)
        parse_error: str | None = None
        parsed: ParseResult[T] | None = None
        chunk_value: Any | None = None
        try:
            parsed = _parse_with_repair(
                raw,
                target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                repair=ctx.opts.repair,
                allow_partial=True,
            )
            if _is_unusable_partial(parsed, ctx.output_kind):
                raise ValueError(
                    "Media chunk parse produced unvalidated partial output "
                    "that does not match the target root kind"
                )
            chunk_value = to_jsonable_python(parsed.value)
            state.all_flags.update(parsed.flags)
            state.worst_score = max(state.worst_score, parsed.score)
        except (ValidationError, ValueError, TypeError) as exc:
            logger.debug("Media chunk parse failed: %s", exc)
            parse_error = str(exc)
            if ctx.opts.chunk_error == "raise":
                raise

        if chunk_value is not None:
            chunk_evidence: list[FieldEvidence] = []
            for path, text in _iter_leaf_values(_value_for_evidence(parsed.value)):
                chunk_evidence.append(
                    FieldEvidence(
                        path=path,
                        value_preview=text[:80] if text else "",
                        char_interval=None,
                        token_interval=None,
                        alignment_status=AlignmentStatus.UNMATCHED,
                        source="vision",
                        attachment_index=chunk.attachment_index,
                        page_index=req.page_index,
                        grounding_method="unmatched",
                    )
                )
            pass_value, leaf_map = _merge_branch_values(
                pass_value,
                chunk_value,
                strategy=ctx.opts.merge_strategy,
                conflicts=pass_conflicts,
                path="/",
                page_index=req.page_index,
            )
            pass_evidence.extend(_remap_leaf_evidence(chunk_evidence, leaf_map))

        if debug:
            state.chunk_debug_entries.append(
                ChunkDebug(
                    chunk_index=req_idx,
                    chunk_text_preview=(chunk.text or "")[:100],
                    raw_output=raw,
                    flags=parsed.flags if parsed is not None else (),
                    score=parsed.score if parsed is not None else 0,
                    error=parse_error,
                )
            )

    if pass_value is None:
        pass_value = _default_merged_value(ctx.output_kind)
    state.merged_value = _merge_values(
        state.merged_value,
        pass_value,
        strategy=ctx.opts.merge_strategy,
        conflicts=state.conflicts,
    )
    state.conflicts.extend(pass_conflicts)
    if state.merged_value is None:
        state.merged_value = _default_merged_value(ctx.output_kind)
    if pass_index == 0:
        state.doc_evidence = pass_evidence
    else:
        state.doc_evidence = _dedupe_field_evidence([*state.doc_evidence, *pass_evidence])
    state.diagnostics = _build_field_diagnostics(
        state.merged_value, state.doc_evidence, source="page"
    )


def _targeted_repair_attempts(opts: ExtractOptions) -> int:
    if _effective_repair_mode(opts) != "targeted":
        return 0
    return max(opts.max_repair_attempts, opts.passes - 1)


def _run_text_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    state: _DocumentState,
    original_prompt: str,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> None:
    for _attempt in range(_targeted_repair_attempts(ctx.opts)):
        errors = _validation_errors_for_state(ctx, state)
        if not errors:
            return
        repair_prompt, target_paths = _build_targeted_repair_prompt(
            ctx=ctx,
            doc=doc,
            state=state,
            original_prompt=original_prompt,
        )
        inferred = _infer_batch(ctx, ctx.provider, [repair_prompt], 1, **native_kwargs)
        raw = inferred[0]
        state.raw_outputs.append(raw)
        parsed = parse(
            raw,
            TypeAdapter(Any),
            parse_options=parse_options,
            is_done=True,
        )
        fragment_value = to_jsonable_python(parsed.value)
        state.merged_value = _apply_targeted_fragment(
            state.merged_value, fragment_value, target_paths
        )
        state.all_flags.update(parsed.flags)
        state.worst_score = max(state.worst_score, parsed.score)
        aligned_fragment = _align_evidence(
            doc.text,
            fragment_value,
            tokenizer=ctx.opts.tokenizer,
            alignment=ctx.opts.alignment,
            offset=0,
        )
        state.doc_evidence = _replace_evidence_for_paths(
            state.doc_evidence,
            aligned_fragment,
            target_paths,
        )
        state.diagnostics = _build_field_diagnostics(
            state.merged_value, state.doc_evidence, source="repair"
        )


async def _arun_text_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    state: _DocumentState,
    original_prompt: str,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> None:
    for _attempt in range(_targeted_repair_attempts(ctx.opts)):
        errors = _validation_errors_for_state(ctx, state)
        if not errors:
            return
        repair_prompt, target_paths = _build_targeted_repair_prompt(
            ctx=ctx,
            doc=doc,
            state=state,
            original_prompt=original_prompt,
        )
        inferred = await _ainfer_batch(ctx, ctx.provider, [repair_prompt], 1, **native_kwargs)
        raw = inferred[0]
        state.raw_outputs.append(raw)
        parsed = parse(
            raw,
            TypeAdapter(Any),
            parse_options=parse_options,
            is_done=True,
        )
        fragment_value = to_jsonable_python(parsed.value)
        state.merged_value = _apply_targeted_fragment(
            state.merged_value, fragment_value, target_paths
        )
        state.all_flags.update(parsed.flags)
        state.worst_score = max(state.worst_score, parsed.score)
        aligned_fragment = _align_evidence(
            doc.text,
            fragment_value,
            tokenizer=ctx.opts.tokenizer,
            alignment=ctx.opts.alignment,
            offset=0,
        )
        state.doc_evidence = _replace_evidence_for_paths(
            state.doc_evidence,
            aligned_fragment,
            target_paths,
        )
        state.diagnostics = _build_field_diagnostics(
            state.merged_value, state.doc_evidence, source="repair"
        )


def _run_media_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    state: _DocumentState,
    request: InferenceRequest,
    chunk: MediaChunk,
    original_prompt: str,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> None:
    for _attempt in range(_targeted_repair_attempts(ctx.opts)):
        errors = _validation_errors_for_state(ctx, state)
        if not errors:
            return
        repair_prompt, target_paths = _build_targeted_repair_prompt(
            ctx=ctx,
            doc=doc,
            state=state,
            original_prompt=original_prompt,
        )
        repair_request = InferenceRequest(
            prompt=repair_prompt,
            attachments=request.attachments,
            document_id=request.document_id,
            document_index=request.document_index,
            attachment_index=request.attachment_index,
            page_index=request.page_index,
            meta=request.meta,
        )
        inferred = _infer_media_batch(ctx, ctx.provider, [repair_request], 1, **native_kwargs)
        raw = inferred[0]
        state.raw_outputs.append(raw)
        parsed = parse(
            raw,
            TypeAdapter(Any),
            parse_options=parse_options,
            is_done=True,
        )
        fragment_value = to_jsonable_python(parsed.value)
        state.merged_value = _apply_targeted_fragment(
            state.merged_value, fragment_value, target_paths
        )
        state.all_flags.update(parsed.flags)
        state.worst_score = max(state.worst_score, parsed.score)
        repair_evidence = [
            FieldEvidence(
                path=path,
                value_preview=text[:80] if text else "",
                char_interval=None,
                token_interval=None,
                alignment_status=AlignmentStatus.UNMATCHED,
                source="vision",
                attachment_index=request.attachment_index,
                page_index=request.page_index,
                grounding_method="unmatched",
            )
            for path, text in _iter_leaf_values(fragment_value)
        ]
        state.doc_evidence = _replace_evidence_for_paths(
            state.doc_evidence,
            repair_evidence,
            target_paths,
        )
        state.diagnostics = _build_field_diagnostics(
            state.merged_value, state.doc_evidence, source="repair"
        )


async def _arun_media_targeted_repair[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    state: _DocumentState,
    request: InferenceRequest,
    chunk: MediaChunk,
    original_prompt: str,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> None:
    for _attempt in range(_targeted_repair_attempts(ctx.opts)):
        errors = _validation_errors_for_state(ctx, state)
        if not errors:
            return
        repair_prompt, target_paths = _build_targeted_repair_prompt(
            ctx=ctx,
            doc=doc,
            state=state,
            original_prompt=original_prompt,
        )
        repair_request = InferenceRequest(
            prompt=repair_prompt,
            attachments=request.attachments,
            document_id=request.document_id,
            document_index=request.document_index,
            attachment_index=request.attachment_index,
            page_index=request.page_index,
            meta=request.meta,
        )
        inferred = await _ainfer_media_batch(
            ctx, ctx.provider, [repair_request], 1, **native_kwargs
        )
        raw = inferred[0]
        state.raw_outputs.append(raw)
        parsed = parse(
            raw,
            TypeAdapter(Any),
            parse_options=parse_options,
            is_done=True,
        )
        fragment_value = to_jsonable_python(parsed.value)
        state.merged_value = _apply_targeted_fragment(
            state.merged_value, fragment_value, target_paths
        )
        state.all_flags.update(parsed.flags)
        state.worst_score = max(state.worst_score, parsed.score)
        repair_evidence = [
            FieldEvidence(
                path=path,
                value_preview=text[:80] if text else "",
                char_interval=None,
                token_interval=None,
                alignment_status=AlignmentStatus.UNMATCHED,
                source="vision",
                attachment_index=request.attachment_index,
                page_index=request.page_index,
                grounding_method="unmatched",
            )
            for path, text in _iter_leaf_values(fragment_value)
        ]
        state.doc_evidence = _replace_evidence_for_paths(
            state.doc_evidence,
            repair_evidence,
            target_paths,
        )
        state.diagnostics = _build_field_diagnostics(
            state.merged_value, state.doc_evidence, source="repair"
        )


def _dedupe_field_evidence(evidence: Sequence[FieldEvidence]) -> list[FieldEvidence]:
    seen: set[tuple[Any, ...]] = set()
    deduped: list[FieldEvidence] = []
    for ev in evidence:
        key = (
            ev.path,
            ev.value_preview,
            ev.char_interval,
            ev.token_interval,
            ev.alignment_status,
            ev.source,
            ev.attachment_index,
            ev.page_index,
            ev.bbox_norm,
            ev.grounding_method,
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(ev)
    return deduped


def _is_missing_hybrid_value(
    value: Any,
    diagnostic: FieldDiagnostic | None = None,
) -> bool:
    if diagnostic is not None:
        return diagnostic.state in {
            FieldState.ABSENT,
            FieldState.ERROR,
            FieldState.NOT_ATTEMPTED,
        }
    return value in (None, "", [], {})


@dataclass(slots=True)
class _HybridMergeTrace:
    page_paths: set[str] = field(default_factory=set)
    whole_paths: set[str] = field(default_factory=set)
    page_aliases: list[tuple[str, str]] = field(default_factory=list)
    whole_aliases: list[tuple[str, str]] = field(default_factory=list)
    unmatched_secondary: list[MergeConflict] = field(default_factory=list)

    def include_page_value(self, value: Any, path: str) -> None:
        self.page_paths.update(_leaf_paths_for_value(value, path))

    def include_whole_value(self, value: Any, path: str) -> None:
        self.whole_paths.update(_leaf_paths_for_value(value, path))

    def merge(self, other: _HybridMergeTrace) -> None:
        self.page_paths.update(other.page_paths)
        self.whole_paths.update(other.whole_paths)
        self.page_aliases.extend(other.page_aliases)
        self.whole_aliases.extend(other.whole_aliases)
        self.unmatched_secondary.extend(other.unmatched_secondary)


def _pointer_path(path: str) -> str:
    return path or "/"


def _pointer_with_child(path: str, token: str | int) -> str:
    return build_json_pointer([*_pointer_tokens(path), str(token)])


def _leaf_paths_for_value(value: Any, path: str) -> set[str]:
    leaf_paths = {leaf_path for leaf_path, _ in _iter_leaf_values(value, path)}
    if leaf_paths:
        return leaf_paths
    if value in ({}, []):
        return {_pointer_path(path)}
    return set()


def _evidence_preview(value: Any) -> str:
    if isinstance(value, str):
        return value[:80]
    return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)[:80]


def _evidence_value_index(
    evidence: Sequence[FieldEvidence],
) -> dict[str, set[str]]:
    values_by_path: dict[str, set[str]] = defaultdict(set)
    for item in evidence:
        values_by_path[item.path].add(item.value_preview)
    return values_by_path


def _conflicts_by_path(conflicts: Sequence[MergeConflict]) -> dict[str, list[MergeConflict]]:
    grouped: dict[str, list[MergeConflict]] = defaultdict(list)
    for conflict in conflicts:
        grouped[conflict.path].append(conflict)
    return grouped


def _diagnostic_at_path(
    diagnostics: dict[str, FieldDiagnostic],
    path: str,
) -> FieldDiagnostic | None:
    return diagnostics.get(_pointer_path(path))


def _relative_leaf_map(value: Any) -> dict[str, str]:
    if value is None:
        return {}
    return {path or "/": preview for path, preview in _iter_leaf_values(value)}


def _normalize_leaf_preview(value: str) -> str:
    normalized = unicodedata.normalize("NFKC", value)
    normalized = re.sub(r"\s+", " ", normalized).strip()
    lowered = normalized.casefold()
    compact = lowered.replace(",", "")
    try:
        numeric = Decimal(compact)
    except InvalidOperation:
        numeric = None
    if numeric is not None:
        return format(numeric.normalize(), "f").rstrip("0").rstrip(".") or "0"

    size_match = re.fullmatch(
        r"([+-]?\d+(?:\.\d+)?)\s*(mm|millimeter|millimeters|cm|centimeter|centimeters)",
        compact,
    )
    if size_match is not None:
        magnitude = Decimal(size_match.group(1))
        unit = size_match.group(2)
        if unit.startswith("cm"):
            magnitude *= Decimal("10")
        return f"{format(magnitude.normalize(), 'f').rstrip('0').rstrip('.') or '0'}mm"
    return lowered


def _normalized_relative_leaf_map(value: Any) -> dict[str, str]:
    return {
        path: _normalize_leaf_preview(preview)
        for path, preview in _relative_leaf_map(value).items()
    }


def _join_relative_pointer(base_path: str, relative_path: str) -> str:
    if relative_path in {"", "/"}:
        return _pointer_path(base_path)
    return build_json_pointer([*_pointer_tokens(base_path), *_pointer_tokens(relative_path)])


def _identity_relative_paths_for_item(
    *,
    item_path: str,
    identity_keys: dict[str, tuple[str, ...]],
) -> tuple[str, ...]:
    path_tokens = _pointer_tokens(item_path)
    best_match: tuple[str, ...] = ()
    best_len = -1
    for candidate_path, relative_paths in identity_keys.items():
        candidate_tokens = _pointer_tokens(candidate_path)
        if len(candidate_tokens) > len(path_tokens):
            continue
        for candidate_token, path_token in zip(candidate_tokens, path_tokens, strict=False):
            if candidate_token != "*" and candidate_token != path_token:
                break
        else:
            if len(candidate_tokens) > best_len:
                best_match = relative_paths
                best_len = len(candidate_tokens)
    return best_match


def _item_match_score(
    page_item: Any,
    whole_item: Any,
    *,
    page_item_path: str,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]],
) -> float:
    if isinstance(page_item, (str, int, float, bool)) or page_item is None:
        return (
            1.0
            if _normalize_leaf_preview(str(page_item)) == _normalize_leaf_preview(str(whole_item))
            else 0.0
        )
    if type(page_item) is not type(whole_item):
        return 0.0
    page_leaves = _normalized_relative_leaf_map(page_item)
    whole_leaves = _normalized_relative_leaf_map(whole_item)
    if not page_leaves and not whole_leaves:
        return 1.0 if page_item == whole_item else 0.0
    common_paths = set(page_leaves) & set(whole_leaves)
    if not common_paths:
        return 0.0

    configured_identity_paths = {
        relative_path
        for relative_path in _identity_relative_paths_for_item(
            item_path=page_item_path,
            identity_keys=identity_keys,
        )
        if relative_path in common_paths
    }
    identity_paths = configured_identity_paths or {
        relative_path
        for relative_path in common_paths
        if field_scope.scope_for(_join_relative_pointer(page_item_path, relative_path)) == "auto"
    }
    paths_to_compare = identity_paths or common_paths
    if any(page_leaves[path] != whole_leaves[path] for path in paths_to_compare):
        return 0.0
    return float(len(paths_to_compare)) + (max(len(page_leaves), len(whole_leaves)) / 100.0)


def _item_identity_signature(
    item: Any,
    *,
    item_path: str,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]],
) -> tuple[tuple[str, str], ...]:
    leaf_map = _normalized_relative_leaf_map(item)
    if not leaf_map:
        return ()
    configured_identity_paths = set(
        _identity_relative_paths_for_item(item_path=item_path, identity_keys=identity_keys)
    )
    identity_items = [
        (relative_path, value)
        for relative_path, value in leaf_map.items()
        if relative_path in configured_identity_paths
        or (
            not configured_identity_paths
            and field_scope.scope_for(_join_relative_pointer(item_path, relative_path)) == "auto"
        )
    ]
    if identity_items:
        return tuple(sorted(identity_items))
    return tuple(sorted(leaf_map.items()))


def _item_sort_key(
    item: Any,
    *,
    item_path: str,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]],
) -> tuple[int, tuple[tuple[str, str], ...], tuple[tuple[str, str], ...]]:
    signature = _item_identity_signature(
        item,
        item_path=item_path,
        field_scope=field_scope,
        identity_keys=identity_keys,
    )
    normalized = _normalized_relative_leaf_map(item)
    return (-len(normalized), signature, tuple(sorted(normalized.items())))


def _add_alias(aliases: list[tuple[str, str]], source_path: str, final_path: str) -> None:
    aliases.append((_pointer_path(source_path), _pointer_path(final_path)))


def _remap_trace(
    trace: _HybridMergeTrace,
    aliases: Sequence[tuple[str, str]],
) -> _HybridMergeTrace:
    if not aliases:
        return trace
    return _HybridMergeTrace(
        page_paths={_apply_path_alias(path, aliases) for path in trace.page_paths},
        whole_paths={_apply_path_alias(path, aliases) for path in trace.whole_paths},
        page_aliases=[
            (_apply_path_alias(source_path, aliases), _apply_path_alias(target_path, aliases))
            for source_path, target_path in trace.page_aliases
        ],
        whole_aliases=[
            (_apply_path_alias(source_path, aliases), _apply_path_alias(target_path, aliases))
            for source_path, target_path in trace.whole_aliases
        ],
        unmatched_secondary=_remap_conflict_paths(trace.unmatched_secondary, aliases),
    )


def _apply_path_alias(path: str, aliases: Sequence[tuple[str, str]]) -> str:
    normalized_path = _pointer_path(path)
    best_source: str | None = None
    best_target: str | None = None
    for source_path, target_path in aliases:
        if normalized_path == source_path or normalized_path.startswith(source_path + "/"):
            if best_source is None or len(source_path) > len(best_source):
                best_source = source_path
                best_target = target_path
    if best_source is None or best_target is None:
        return normalized_path
    if normalized_path == best_source:
        return best_target
    suffix_tokens = _pointer_tokens(normalized_path)[len(_pointer_tokens(best_source)) :]
    return build_json_pointer([*_pointer_tokens(best_target), *suffix_tokens])


def _remap_evidence_paths(
    evidence: Sequence[FieldEvidence],
    aliases: Sequence[tuple[str, str]],
) -> list[FieldEvidence]:
    if not aliases:
        return list(evidence)
    remapped: list[FieldEvidence] = []
    for item in evidence:
        remapped.append(replace(item, path=_apply_path_alias(item.path, aliases)))
    return remapped


def _remap_conflict_paths(
    conflicts: Sequence[MergeConflict],
    aliases: Sequence[tuple[str, str]],
) -> list[MergeConflict]:
    if not aliases:
        return list(conflicts)
    remapped: list[MergeConflict] = []
    for conflict in conflicts:
        remapped.append(replace(conflict, path=_apply_path_alias(conflict.path, aliases)))
    return remapped


def _should_prefer_whole_for_auto_scalar(
    *,
    path: str,
    page_value: Any,
    whole_value: Any,
    page_diagnostic: FieldDiagnostic | None,
    whole_diagnostic: FieldDiagnostic | None,
    page_values_by_path: dict[str, set[str]],
    page_conflicts_by_path: dict[str, list[MergeConflict]],
) -> bool:
    if _is_missing_hybrid_value(page_value, page_diagnostic):
        return True
    if _is_missing_hybrid_value(whole_value, whole_diagnostic):
        return False
    if page_value == whole_value:
        return False
    if len(page_values_by_path.get(_pointer_path(path), set())) > 1:
        return True
    return bool(page_conflicts_by_path.get(_pointer_path(path)))


def _reconcile_hybrid_value(
    *,
    page_value: Any,
    whole_value: Any,
    path: str,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]],
    page_diagnostics: dict[str, FieldDiagnostic],
    whole_diagnostics: dict[str, FieldDiagnostic],
    page_values_by_path: dict[str, set[str]],
    page_conflicts_by_path: dict[str, list[MergeConflict]],
) -> tuple[Any, _HybridMergeTrace]:
    trace = _HybridMergeTrace()
    pointer_path = _pointer_path(path)
    scope = field_scope.scope_for(pointer_path)
    page_diagnostic = _diagnostic_at_path(page_diagnostics, pointer_path)
    whole_diagnostic = _diagnostic_at_path(whole_diagnostics, pointer_path)

    if _is_missing_hybrid_value(page_value, page_diagnostic):
        if whole_value is None:
            return None, trace
        trace.include_whole_value(whole_value, path)
        return whole_value, trace
    if _is_missing_hybrid_value(whole_value, whole_diagnostic):
        trace.include_page_value(page_value, path)
        return page_value, trace
    if page_diagnostic is not None and page_diagnostic.state == FieldState.EMPTY:
        if scope in {"auto", "local", "span"} or whole_diagnostic is None:
            trace.include_page_value(page_value, path)
            return page_value, trace
    if whole_diagnostic is not None and whole_diagnostic.state == FieldState.EMPTY:
        if scope == "global":
            trace.include_whole_value(whole_value, path)
            return whole_value, trace

    if isinstance(page_value, dict) or isinstance(whole_value, dict):
        if not isinstance(page_value, dict):
            trace.include_whole_value(whole_value, path)
            return whole_value, trace
        if not isinstance(whole_value, dict):
            trace.include_page_value(page_value, path)
            return page_value, trace
        if page_diagnostic is not None and page_diagnostic.state == FieldState.EMPTY:
            trace.include_page_value(page_value, path)
            return page_value, trace
        if whole_diagnostic is not None and whole_diagnostic.state == FieldState.EMPTY:
            trace.include_whole_value(whole_value, path)
            return whole_value, trace
        merged: dict[str, Any] = {}
        for key in sorted(set(page_value) | set(whole_value)):
            child_path = _pointer_with_child(path, key)
            child_value, child_trace = _reconcile_hybrid_value(
                page_value=page_value.get(key),
                whole_value=whole_value.get(key),
                path=child_path,
                field_scope=field_scope,
                identity_keys=identity_keys,
                page_diagnostics=page_diagnostics,
                whole_diagnostics=whole_diagnostics,
                page_values_by_path=page_values_by_path,
                page_conflicts_by_path=page_conflicts_by_path,
            )
            if child_value is not None:
                merged[key] = child_value
            trace.merge(child_trace)
        return merged, trace

    if isinstance(page_value, list) or isinstance(whole_value, list):
        if not isinstance(page_value, list):
            trace.include_whole_value(whole_value, path)
            return whole_value, trace
        if not isinstance(whole_value, list):
            trace.include_page_value(page_value, path)
            return page_value, trace
        if page_diagnostic is not None and page_diagnostic.state == FieldState.EMPTY:
            trace.include_page_value(page_value, path)
            return page_value, trace
        if whole_diagnostic is not None and whole_diagnostic.state == FieldState.EMPTY:
            trace.include_whole_value(whole_value, path)
            return whole_value, trace
        merged_list, list_trace = _reconcile_hybrid_list(
            page_items=page_value,
            whole_items=whole_value,
            path=path,
            field_scope=field_scope,
            identity_keys=identity_keys,
            page_diagnostics=page_diagnostics,
            whole_diagnostics=whole_diagnostics,
            page_values_by_path=page_values_by_path,
            page_conflicts_by_path=page_conflicts_by_path,
        )
        trace.merge(list_trace)
        return merged_list, trace

    prefer_whole = scope == "global" or (
        scope == "auto"
        and _should_prefer_whole_for_auto_scalar(
            path=pointer_path,
            page_value=page_value,
            whole_value=whole_value,
            page_diagnostic=page_diagnostic,
            whole_diagnostic=whole_diagnostic,
            page_values_by_path=page_values_by_path,
            page_conflicts_by_path=page_conflicts_by_path,
        )
    )
    prefer_page = scope in {"local", "span"}

    if page_value == whole_value:
        trace.page_paths.add(pointer_path)
        trace.whole_paths.add(pointer_path)
        return whole_value, trace
    if prefer_whole:
        trace.whole_paths.add(pointer_path)
        return whole_value, trace
    if prefer_page or scope == "auto":
        trace.page_paths.add(pointer_path)
        return page_value, trace
    trace.whole_paths.add(pointer_path)
    return whole_value, trace


def _reconcile_hybrid_list(
    *,
    page_items: list[Any],
    whole_items: list[Any],
    path: str,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]],
    page_diagnostics: dict[str, FieldDiagnostic],
    whole_diagnostics: dict[str, FieldDiagnostic],
    page_values_by_path: dict[str, set[str]],
    page_conflicts_by_path: dict[str, list[MergeConflict]],
) -> tuple[list[Any], _HybridMergeTrace]:
    trace = _HybridMergeTrace()
    scope = field_scope.scope_for(_pointer_path(path))
    if scope == "global":
        primary_branch = "whole"
        primary_items = whole_items
        secondary_items = page_items
    else:
        primary_branch = "page"
        primary_items = page_items
        secondary_items = whole_items

    used_secondary: set[int] = set()
    merged_items: list[Any] = []
    primary_order = list(range(len(primary_items)))
    secondary_order = sorted(
        range(len(secondary_items)),
        key=lambda idx: _item_sort_key(
            secondary_items[idx],
            item_path=_pointer_with_child(path, idx),
            field_scope=field_scope,
            identity_keys=identity_keys,
        ),
    )

    def _leaf_map_is_subset(subset_map: dict[str, str], superset_map: dict[str, str]) -> bool:
        return bool(subset_map) and all(
            superset_map.get(path) == value for path, value in subset_map.items()
        )

    def _duplicate_relation(
        existing_item: Any, candidate_item: Any
    ) -> Literal["equal", "existing_superset", "candidate_superset"] | None:
        if type(existing_item) is not type(candidate_item):
            return None
        if isinstance(existing_item, (str, int, float, bool)) or existing_item is None:
            return (
                "equal"
                if _normalize_leaf_preview(str(existing_item))
                == _normalize_leaf_preview(str(candidate_item))
                else None
            )
        existing_leaves = _normalized_relative_leaf_map(existing_item)
        candidate_leaves = _normalized_relative_leaf_map(candidate_item)
        if existing_leaves == candidate_leaves:
            return "equal"
        if isinstance(existing_item, dict) and isinstance(candidate_item, dict):
            if _leaf_map_is_subset(candidate_leaves, existing_leaves):
                return "existing_superset"
            if _leaf_map_is_subset(existing_leaves, candidate_leaves):
                return "candidate_superset"
        return None

    def _find_safe_duplicate(
        candidate_item: Any,
    ) -> tuple[int, Literal["equal", "existing_superset", "candidate_superset"]] | None:
        matches: list[tuple[int, Literal["equal", "existing_superset", "candidate_superset"]]] = []
        for idx, existing_item in enumerate(merged_items):
            relation = _duplicate_relation(existing_item, candidate_item)
            if relation is not None:
                matches.append((idx, relation))
        if len(matches) != 1:
            return None
        return matches[0]

    candidate_matches: list[
        tuple[
            float,
            int,
            tuple[tuple[tuple[str, str], ...], int, tuple[tuple[str, str], ...]],
            int,
        ]
    ] = []
    for primary_index in primary_order:
        primary_item = primary_items[primary_index]
        for secondary_index in secondary_order:
            secondary_item = secondary_items[secondary_index]
            page_item_path = _pointer_with_child(
                path,
                primary_index if primary_branch == "page" else secondary_index,
            )
            score = _item_match_score(
                primary_item if primary_branch == "page" else secondary_item,
                secondary_item if primary_branch == "page" else primary_item,
                page_item_path=page_item_path,
                field_scope=field_scope,
                identity_keys=identity_keys,
            )
            if score <= 0:
                continue
            candidate_matches.append(
                (
                    score,
                    primary_index,
                    _item_sort_key(
                        secondary_item,
                        item_path=_pointer_with_child(path, secondary_index),
                        field_scope=field_scope,
                        identity_keys=identity_keys,
                    ),
                    secondary_index,
                )
            )

    primary_to_secondary: dict[int, int] = {}
    for _, primary_index, _, secondary_index in sorted(
        candidate_matches,
        key=lambda item: (-item[0], item[1], item[2], item[3]),
    ):
        if primary_index in primary_to_secondary or secondary_index in used_secondary:
            continue
        primary_to_secondary[primary_index] = secondary_index
        used_secondary.add(secondary_index)

    used_secondary.clear()
    for primary_index in primary_order:
        primary_item = primary_items[primary_index]
        best_secondary_index = primary_to_secondary.get(primary_index)

        final_index = len(merged_items)
        final_item_path = _pointer_with_child(path, final_index)
        primary_item_path = _pointer_with_child(path, primary_index)
        item_trace = _HybridMergeTrace()
        if primary_branch == "page":
            _add_alias(item_trace.page_aliases, primary_item_path, final_item_path)
        else:
            _add_alias(item_trace.whole_aliases, primary_item_path, final_item_path)

        if best_secondary_index is None:
            if primary_branch == "page":
                item_value, value_trace = _reconcile_hybrid_value(
                    page_value=primary_item,
                    whole_value=None,
                    path=final_item_path,
                    field_scope=field_scope,
                    identity_keys=identity_keys,
                    page_diagnostics=page_diagnostics,
                    whole_diagnostics=whole_diagnostics,
                    page_values_by_path=page_values_by_path,
                    page_conflicts_by_path=page_conflicts_by_path,
                )
            else:
                item_value, value_trace = _reconcile_hybrid_value(
                    page_value=None,
                    whole_value=primary_item,
                    path=final_item_path,
                    field_scope=field_scope,
                    identity_keys=identity_keys,
                    page_diagnostics=page_diagnostics,
                    whole_diagnostics=whole_diagnostics,
                    page_values_by_path=page_values_by_path,
                    page_conflicts_by_path=page_conflicts_by_path,
                )
        else:
            used_secondary.add(best_secondary_index)
            secondary_item = secondary_items[best_secondary_index]
            secondary_item_path = _pointer_with_child(path, best_secondary_index)
            if primary_branch == "page":
                _add_alias(item_trace.whole_aliases, secondary_item_path, final_item_path)
                item_value, value_trace = _reconcile_hybrid_value(
                    page_value=primary_item,
                    whole_value=secondary_item,
                    path=final_item_path,
                    field_scope=field_scope,
                    identity_keys=identity_keys,
                    page_diagnostics=page_diagnostics,
                    whole_diagnostics=whole_diagnostics,
                    page_values_by_path=page_values_by_path,
                    page_conflicts_by_path=page_conflicts_by_path,
                )
            else:
                _add_alias(item_trace.page_aliases, secondary_item_path, final_item_path)
                item_value, value_trace = _reconcile_hybrid_value(
                    page_value=secondary_item,
                    whole_value=primary_item,
                    path=final_item_path,
                    field_scope=field_scope,
                    identity_keys=identity_keys,
                    page_diagnostics=page_diagnostics,
                    whole_diagnostics=whole_diagnostics,
                    page_values_by_path=page_values_by_path,
                    page_conflicts_by_path=page_conflicts_by_path,
                )
        merged_items.append(item_value)
        item_trace.merge(value_trace)
        trace.merge(item_trace)

    for secondary_index in secondary_order:
        secondary_item = secondary_items[secondary_index]
        if secondary_index in used_secondary:
            continue
        secondary_item_path = _pointer_with_child(path, secondary_index)
        trace.unmatched_secondary.append(
            MergeConflict(
                path=secondary_item_path,
                existing_preview="",
                incoming_preview=_evidence_preview(secondary_item),
                resolution="dropped_unmatched",
            )
        )

    return merged_items, trace


def _paths_overlap(path: str, leaf_paths: set[str]) -> bool:
    normalized = _pointer_path(path)
    for leaf_path in leaf_paths:
        if leaf_path == normalized or leaf_path.startswith(normalized + "/"):
            return True
        if normalized.startswith(leaf_path + "/"):
            return True
    return False


def _build_hybrid_evidence(
    *,
    merged_value: Any,
    page_evidence: Sequence[FieldEvidence],
    whole_evidence: Sequence[FieldEvidence],
    trace: _HybridMergeTrace,
    doc: Document | None = None,
) -> list[FieldEvidence]:
    remapped_page = _remap_evidence_paths(page_evidence, trace.page_aliases)
    remapped_whole = _remap_evidence_paths(whole_evidence, trace.whole_aliases)
    doc_page_texts = _extract_pdf_page_texts_for_backfill(doc)
    page_by_path: dict[str, list[FieldEvidence]] = defaultdict(list)
    whole_by_path: dict[str, list[FieldEvidence]] = defaultdict(list)
    for item in remapped_page:
        page_by_path[item.path].append(item)
    for item in remapped_whole:
        whole_by_path[item.path].append(item)

    selected: list[FieldEvidence] = []
    for path, raw_value in _iter_leaf_values(merged_value):
        pointer_path = _pointer_path(path)
        preview = _evidence_preview(raw_value)
        page_exact = [
            item for item in page_by_path.get(pointer_path, []) if item.value_preview == preview
        ]
        whole_exact = [
            item for item in whole_by_path.get(pointer_path, []) if item.value_preview == preview
        ]
        if page_exact:
            selected.extend(page_exact)
            continue
        doc_backfill = _backfill_document_page_evidence(
            path=pointer_path,
            preview=preview,
            whole_candidates=whole_exact or whole_by_path.get(pointer_path, []),
            doc_page_texts=doc_page_texts,
        )
        if doc_backfill:
            selected.extend(doc_backfill)
            continue
        if pointer_path in trace.page_paths and page_by_path.get(pointer_path):
            selected.extend(page_by_path[pointer_path])
            continue
        if whole_exact:
            selected.extend(whole_exact)
            continue
        if pointer_path in trace.whole_paths and whole_by_path.get(pointer_path):
            selected.extend(whole_by_path[pointer_path])
            continue
        if page_by_path.get(pointer_path):
            selected.extend(page_by_path[pointer_path])
            continue
        if whole_by_path.get(pointer_path):
            selected.extend(whole_by_path[pointer_path])
    return _dedupe_field_evidence(selected)


def _extract_pdf_page_texts_for_backfill(doc: Document | None) -> list[tuple[int, str]]:
    if doc is None or not doc.attachments:
        return []
    try:
        from .media.preprocessing import extract_pdf_page_texts
    except ImportError:
        return []

    from .media.attachments import AttachmentKind

    page_texts: list[tuple[int, str]] = []
    for attachment in doc.attachments:
        if attachment.kind is not AttachmentKind.PDF:
            continue
        for page_index, page_text in extract_pdf_page_texts(
            attachment.source,
            page_indices=attachment.page_indices,
        ):
            page_texts.append((page_index + 1, page_text))
    return page_texts


def _page_text_matches_preview(page_text: str, preview: str) -> bool:
    if not preview:
        return False
    if preview in page_text:
        return True

    normalized_preview = " ".join(preview.split())
    normalized_page_text = " ".join(page_text.split())
    if normalized_preview and normalized_preview in normalized_page_text:
        return True

    try:
        expected_number = float(preview.replace(",", ""))
    except ValueError:
        return False
    for match in re.finditer(r"\d+(?:,\d{3})*(?:\.\d+)?", page_text):
        try:
            actual_number = float(match.group().replace(",", ""))
        except ValueError:
            continue
        if actual_number == expected_number:
            return True
    return False


def _backfill_document_page_evidence(
    *,
    path: str,
    preview: str,
    whole_candidates: Sequence[FieldEvidence],
    doc_page_texts: Sequence[tuple[int, str]],
) -> list[FieldEvidence]:
    if not whole_candidates or not doc_page_texts:
        return []
    matched_pages = [
        page_index
        for page_index, page_text in doc_page_texts
        if _page_text_matches_preview(page_text, preview)
    ]
    if not matched_pages:
        return []
    template = whole_candidates[0]
    return [
        replace(
            template,
            path=path,
            value_preview=preview,
            page_index=page_index,
        )
        for page_index in matched_pages
    ]


def _build_hybrid_conflicts(
    *,
    merged_value: Any,
    page_conflicts: Sequence[MergeConflict],
    whole_conflicts: Sequence[MergeConflict],
    trace: _HybridMergeTrace,
) -> list[MergeConflict]:
    final_leaf_paths = _leaf_paths_for_value(merged_value, "")
    remapped_page = _remap_conflict_paths(page_conflicts, trace.page_aliases)
    remapped_whole = _remap_conflict_paths(whole_conflicts, trace.whole_aliases)
    filtered = [
        conflict
        for conflict in [*remapped_page, *remapped_whole, *trace.unmatched_secondary]
        if conflict.resolution == "dropped_unmatched"
        or _paths_overlap(conflict.path, final_leaf_paths)
    ]
    return filtered


def _branch_has_contribution(
    *,
    branch: Literal["page", "whole"],
    trace: _HybridMergeTrace,
    evidence: Sequence[FieldEvidence],
    merged_value: Any,
    diagnostics: dict[str, FieldDiagnostic],
) -> bool:
    if branch == "page" and trace.page_paths:
        return True
    if branch == "whole" and trace.whole_paths:
        return True
    branch_evidence = (
        _remap_evidence_paths(evidence, trace.page_aliases)
        if branch == "page"
        else _remap_evidence_paths(evidence, trace.whole_aliases)
    )
    selected_paths = {
        path
        for path, diagnostic in diagnostics.items()
        if diagnostic.state in {FieldState.PRESENT, FieldState.EMPTY}
    } or {path for path, value in _iter_leaf_values(merged_value) if value is not None}
    return any(item.path in selected_paths for item in branch_evidence)


def _build_merged_diagnostics(
    *,
    merged_value: Any,
    doc_evidence: Sequence[FieldEvidence],
    trace: _HybridMergeTrace,
    page_diagnostics: dict[str, FieldDiagnostic],
    whole_diagnostics: dict[str, FieldDiagnostic],
) -> dict[str, FieldDiagnostic]:
    diagnostics = _build_field_diagnostics(merged_value, doc_evidence)
    for path in trace.page_paths:
        if path in diagnostics and path in page_diagnostics:
            diagnostics[path] = page_diagnostics[path]
    for path in trace.whole_paths:
        if path in diagnostics and path in whole_diagnostics:
            diagnostics[path] = whole_diagnostics[path]
    return diagnostics


def _merge_hybrid_states(
    *,
    page_state: _DocumentState,
    whole_state: _DocumentState,
    field_scope: Any,
    identity_keys: dict[str, tuple[str, ...]] | None = None,
    doc: Document | None = None,
    output_kind: _RootKind | None = None,
) -> _DocumentState:
    resolved_identity_keys = identity_keys or {}
    page_values_by_path = _evidence_value_index(page_state.doc_evidence)
    page_conflicts_by_path = _conflicts_by_path(page_state.conflicts)
    merged_value, trace = _reconcile_hybrid_value(
        page_value=page_state.merged_value,
        whole_value=whole_state.merged_value,
        path="",
        field_scope=field_scope,
        identity_keys=resolved_identity_keys,
        page_diagnostics=page_state.diagnostics,
        whole_diagnostics=whole_state.diagnostics,
        page_values_by_path=page_values_by_path,
        page_conflicts_by_path=page_conflicts_by_path,
    )
    if merged_value is None:
        merged_value = _default_merged_value(output_kind)

    doc_evidence = _build_hybrid_evidence(
        merged_value=merged_value,
        page_evidence=page_state.doc_evidence,
        whole_evidence=whole_state.doc_evidence,
        trace=trace,
        doc=doc,
    )
    conflicts = _build_hybrid_conflicts(
        merged_value=merged_value,
        page_conflicts=page_state.conflicts,
        whole_conflicts=whole_state.conflicts,
        trace=trace,
    )
    page_contributed = _branch_has_contribution(
        branch="page",
        trace=trace,
        evidence=page_state.doc_evidence,
        merged_value=merged_value,
        diagnostics=page_state.diagnostics,
    )
    whole_contributed = _branch_has_contribution(
        branch="whole",
        trace=trace,
        evidence=whole_state.doc_evidence,
        merged_value=merged_value,
        diagnostics=whole_state.diagnostics,
    )
    contributing_states: list[_DocumentState] = []
    if page_contributed:
        contributing_states.append(page_state)
    if whole_contributed:
        contributing_states.append(whole_state)

    return _DocumentState(
        merged_value=merged_value,
        raw_outputs=[output for state in contributing_states for output in state.raw_outputs],
        all_flags=set().union(*(state.all_flags for state in contributing_states)),
        worst_score=max((state.worst_score for state in contributing_states), default=0),
        chunk_debug_entries=[
            entry for state in contributing_states for entry in state.chunk_debug_entries
        ],
        doc_evidence=doc_evidence,
        conflicts=conflicts,
        diagnostics=_build_merged_diagnostics(
            merged_value=merged_value,
            doc_evidence=doc_evidence,
            trace=trace,
            page_diagnostics=page_state.diagnostics,
            whole_diagnostics=whole_state.diagnostics,
        ),
    )


def _ensure_hybrid_output_kind_supported(output_kind: _RootKind | None) -> None:
    if output_kind in {"object", "array", "scalar"}:
        return
    raise NotImplementedError(
        "strategy.plan='hybrid' currently supports object, array, and scalar schemas only"
    )


def _run_hybrid_media_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    page_media_chunks: list[MediaChunk],
    whole_media_chunks: list[MediaChunk],
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    _ensure_hybrid_output_kind_supported(ctx.output_kind)

    media_requests = _build_media_inference_requests(ctx, doc, page_media_chunks)
    single_req = _build_single_inference_request(ctx, doc, whole_media_chunks)
    rendered_prompt_preview = single_req.prompt[:500] if debug else None
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    page_state = _DocumentState()
    whole_state = _DocumentState()
    aggregate_chunk = MediaChunk(
        attachment=whole_media_chunks[0].attachment,
        attachment_index=None,
        page_index=None,
        text=whole_media_chunks[0].text,
    )

    def _run_whole_branch() -> list[str]:
        return _infer_media_batch(ctx, ctx.provider, [single_req], batch_length, **native_kwargs)

    def _run_page_branch() -> list[str]:
        if max_workers <= 1:
            return _infer_media_batch(
                ctx, ctx.provider, media_requests, batch_length, **native_kwargs
            )
        return _infer_media_batch_parallel(
            ctx,
            ctx.provider,
            media_requests,
            batch_length,
            max_workers,
            **native_kwargs,
        )

    if isinstance(ctx.provider, SupportsAsyncMediaInfer):
        whole_inferred = _run_whole_branch()
        page_inferred = _run_page_branch()
    else:
        whole_inferred, page_inferred = _run_parallel_pair(_run_whole_branch, _run_page_branch)
    _accumulate_media_pass(
        pass_index=0,
        state=whole_state,
        media_requests=[single_req],
        media_chunks=[aggregate_chunk],
        inferred=whole_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    _accumulate_media_pass(
        pass_index=0,
        state=page_state,
        media_requests=media_requests,
        media_chunks=page_media_chunks,
        inferred=page_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    merged_state = _merge_hybrid_states(
        page_state=page_state,
        whole_state=whole_state,
        field_scope=ctx.resolved_strategy.field_scope,
        identity_keys=ctx.resolved_strategy.identity_keys,
        doc=doc,
        output_kind=ctx.output_kind,
    )
    _run_media_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=merged_state,
        request=single_req,
        chunk=aggregate_chunk,
        original_prompt=single_req.prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )

    return merged_state, rendered_prompt_preview


async def _arun_hybrid_media_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    page_media_chunks: list[MediaChunk],
    whole_media_chunks: list[MediaChunk],
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    _ensure_hybrid_output_kind_supported(ctx.output_kind)

    media_requests = _build_media_inference_requests(ctx, doc, page_media_chunks)
    single_req = _build_single_inference_request(ctx, doc, whole_media_chunks)
    rendered_prompt_preview = single_req.prompt[:500] if debug else None
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    page_state = _DocumentState()
    whole_state = _DocumentState()
    aggregate_chunk = MediaChunk(
        attachment=whole_media_chunks[0].attachment,
        attachment_index=None,
        page_index=None,
        text=whole_media_chunks[0].text,
    )

    def _run_whole_branch() -> Any:
        return _ainfer_media_batch(ctx, ctx.provider, [single_req], batch_length, **native_kwargs)

    def _run_page_branch() -> Any:
        if max_workers <= 1:
            return _ainfer_media_batch(
                ctx, ctx.provider, media_requests, batch_length, **native_kwargs
            )
        return _ainfer_media_batch_parallel(
            ctx,
            ctx.provider,
            media_requests,
            batch_length,
            max_workers,
            **native_kwargs,
        )

    whole_inferred, page_inferred = await _arun_parallel_pair(
        _run_whole_branch,
        _run_page_branch,
    )
    _accumulate_media_pass(
        pass_index=0,
        state=whole_state,
        media_requests=[single_req],
        media_chunks=[aggregate_chunk],
        inferred=whole_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    _accumulate_media_pass(
        pass_index=0,
        state=page_state,
        media_requests=media_requests,
        media_chunks=page_media_chunks,
        inferred=page_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    merged_state = _merge_hybrid_states(
        page_state=page_state,
        whole_state=whole_state,
        field_scope=ctx.resolved_strategy.field_scope,
        identity_keys=ctx.resolved_strategy.identity_keys,
        doc=doc,
        output_kind=ctx.output_kind,
    )
    await _arun_media_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=merged_state,
        request=single_req,
        chunk=aggregate_chunk,
        original_prompt=single_req.prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )

    return merged_state, rendered_prompt_preview


def _run_hybrid_text_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    _ensure_hybrid_output_kind_supported(ctx.output_kind)

    chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
    whole_prompt = _build_single_text_prompt(ctx, doc)
    rendered_prompt_preview = whole_prompt[:500] if debug else None
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    page_state = _DocumentState()
    whole_state = _DocumentState()
    aggregate_chunk = TextChunk(text=doc.text, start=0, end=len(doc.text))

    def _run_whole_branch() -> list[str]:
        return _infer_batch(ctx, ctx.provider, [whole_prompt], batch_length, **native_kwargs)

    def _run_page_branch() -> list[str]:
        if max_workers <= 1:
            return _infer_batch(ctx, ctx.provider, chunk_prompts, batch_length, **native_kwargs)
        return _infer_batch_parallel(
            ctx,
            ctx.provider,
            chunk_prompts,
            batch_length,
            max_workers,
            **native_kwargs,
        )

    if _provider_supports_async_text(ctx.provider):
        whole_inferred = _run_whole_branch()
        page_inferred = _run_page_branch()
    else:
        whole_inferred, page_inferred = _run_parallel_pair(_run_whole_branch, _run_page_branch)
    _accumulate_pass(
        pass_index=0,
        state=whole_state,
        chunks=[aggregate_chunk],
        inferred=whole_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    _accumulate_pass(
        pass_index=0,
        state=page_state,
        chunks=chunks,
        inferred=page_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    merged_state = _merge_hybrid_states(
        page_state=page_state,
        whole_state=whole_state,
        field_scope=ctx.resolved_strategy.field_scope,
        identity_keys=ctx.resolved_strategy.identity_keys,
        doc=doc,
        output_kind=ctx.output_kind,
    )
    _run_text_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=merged_state,
        original_prompt=whole_prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )

    return merged_state, rendered_prompt_preview


async def _arun_hybrid_text_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    _ensure_hybrid_output_kind_supported(ctx.output_kind)

    chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
    whole_prompt = _build_single_text_prompt(ctx, doc)
    rendered_prompt_preview = whole_prompt[:500] if debug else None
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    page_state = _DocumentState()
    whole_state = _DocumentState()
    aggregate_chunk = TextChunk(text=doc.text, start=0, end=len(doc.text))

    def _run_whole_branch() -> Any:
        return _ainfer_batch(ctx, ctx.provider, [whole_prompt], batch_length, **native_kwargs)

    def _run_page_branch() -> Any:
        if max_workers <= 1:
            return _ainfer_batch(ctx, ctx.provider, chunk_prompts, batch_length, **native_kwargs)
        return _ainfer_batch_parallel(
            ctx,
            ctx.provider,
            chunk_prompts,
            batch_length,
            max_workers,
            **native_kwargs,
        )

    whole_inferred, page_inferred = await _arun_parallel_pair(
        _run_whole_branch,
        _run_page_branch,
    )
    _accumulate_pass(
        pass_index=0,
        state=whole_state,
        chunks=[aggregate_chunk],
        inferred=whole_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    _accumulate_pass(
        pass_index=0,
        state=page_state,
        chunks=chunks,
        inferred=page_inferred,
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )

    merged_state = _merge_hybrid_states(
        page_state=page_state,
        whole_state=whole_state,
        field_scope=ctx.resolved_strategy.field_scope,
        identity_keys=ctx.resolved_strategy.identity_keys,
        doc=doc,
        output_kind=ctx.output_kind,
    )
    await _arun_text_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=merged_state,
        original_prompt=whole_prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )

    return merged_state, rendered_prompt_preview


def _run_fused_media_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    media_chunks: list[MediaChunk],
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    media_chunks = _resolve_fused_media_chunks(doc, media_chunks)
    media_mode_chunks = [chunk for chunk in media_chunks if chunk.mode != "text_only"]
    text_only_chunks = [chunk for chunk in media_chunks if chunk.mode == "text_only"]
    media_requests = _build_media_inference_requests(ctx, doc, media_mode_chunks)
    rendered_prompt_preview = media_requests[0].prompt[:500] if (debug and media_requests) else None
    state = _DocumentState()
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    if media_requests:
        if max_workers <= 1:
            inferred = _infer_media_batch(
                ctx, ctx.provider, media_requests, batch_length, **native_kwargs
            )
        else:
            inferred = _infer_media_batch_parallel(
                ctx,
                ctx.provider,
                media_requests,
                batch_length,
                max_workers,
                **native_kwargs,
            )
        _accumulate_media_pass(
            pass_index=0,
            state=state,
            media_requests=media_requests,
            media_chunks=media_mode_chunks,
            inferred=inferred,
            target=target,
            ctx=ctx,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    if text_only_chunks:
        text_prompts = [
            _render_prompt(
                ctx.prompt_obj,
                schema_text=ctx.schema_text,
                examples=ctx.normalized_examples,
                question=_media_chunk_question(doc, chunk),
                format_handler=ctx.format_handler,
                additional_context=doc.additional_context,
                output_kind=ctx.output_kind,
                native_mode=ctx.use_native_schema,
            )
            for chunk in text_only_chunks
        ]
        text_chunks = [
            TextChunk(
                text=_media_chunk_question(doc, chunk),
                start=0,
                end=len(_media_chunk_question(doc, chunk)),
            )
            for chunk in text_only_chunks
        ]
        inferred = _infer_batch(ctx, ctx.provider, text_prompts, batch_length, **native_kwargs)
        _accumulate_pass(
            pass_index=0,
            state=state,
            chunks=text_chunks,
            inferred=inferred,
            target=target,
            ctx=ctx,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    single_req = _build_single_inference_request(ctx, doc, media_mode_chunks or media_chunks)
    aggregate_chunk = MediaChunk(
        attachment=(media_mode_chunks or media_chunks)[0].attachment,
        attachment_index=None,
        page_index=None,
        text=doc.text,
        mode="fused",
    )
    _run_media_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=state,
        request=single_req,
        chunk=aggregate_chunk,
        original_prompt=single_req.prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )
    return state, rendered_prompt_preview


async def _arun_fused_media_extraction[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    media_chunks: list[MediaChunk],
    target: type[T] | TypeAdapter[T],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    native_kwargs: dict[str, Any],
) -> tuple[_DocumentState, str | None]:
    media_chunks = _resolve_fused_media_chunks(doc, media_chunks)
    media_mode_chunks = [chunk for chunk in media_chunks if chunk.mode != "text_only"]
    text_only_chunks = [chunk for chunk in media_chunks if chunk.mode == "text_only"]
    media_requests = _build_media_inference_requests(ctx, doc, media_mode_chunks)
    rendered_prompt_preview = media_requests[0].prompt[:500] if (debug and media_requests) else None
    state = _DocumentState()
    batch_length = max(1, ctx.opts.batch_length)
    max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))
    if media_requests:
        if max_workers <= 1:
            inferred = await _ainfer_media_batch(
                ctx, ctx.provider, media_requests, batch_length, **native_kwargs
            )
        else:
            inferred = await _ainfer_media_batch_parallel(
                ctx,
                ctx.provider,
                media_requests,
                batch_length,
                max_workers,
                **native_kwargs,
            )
        _accumulate_media_pass(
            pass_index=0,
            state=state,
            media_requests=media_requests,
            media_chunks=media_mode_chunks,
            inferred=inferred,
            target=target,
            ctx=ctx,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    if text_only_chunks:
        text_prompts = [
            _render_prompt(
                ctx.prompt_obj,
                schema_text=ctx.schema_text,
                examples=ctx.normalized_examples,
                question=_media_chunk_question(doc, chunk),
                format_handler=ctx.format_handler,
                additional_context=doc.additional_context,
                output_kind=ctx.output_kind,
                native_mode=ctx.use_native_schema,
            )
            for chunk in text_only_chunks
        ]
        text_chunks = [
            TextChunk(
                text=_media_chunk_question(doc, chunk),
                start=0,
                end=len(_media_chunk_question(doc, chunk)),
            )
            for chunk in text_only_chunks
        ]
        inferred = await _ainfer_batch(
            ctx, ctx.provider, text_prompts, batch_length, **native_kwargs
        )
        _accumulate_pass(
            pass_index=0,
            state=state,
            chunks=text_chunks,
            inferred=inferred,
            target=target,
            ctx=ctx,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    single_req = _build_single_inference_request(ctx, doc, media_mode_chunks or media_chunks)
    aggregate_chunk = MediaChunk(
        attachment=(media_mode_chunks or media_chunks)[0].attachment,
        attachment_index=None,
        page_index=None,
        text=doc.text,
        mode="fused",
    )
    await _arun_media_targeted_repair(
        ctx=ctx,
        doc=doc,
        state=state,
        request=single_req,
        chunk=aggregate_chunk,
        original_prompt=single_req.prompt,
        target=target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        native_kwargs=native_kwargs,
    )
    return state, rendered_prompt_preview


def _streaming_mode_error(detail: str) -> NotImplementedError:
    return NotImplementedError(
        "extract_stream currently supports only single-request extraction. " + detail
    )


def _make_stream_event[T](
    *,
    value: Any,
    doc: Document,
    raw_text: str | None,
    flags: Sequence[str],
    score: int,
    is_final: bool,
    result: ExtractResult[T] | None = None,
    attachment_index: int | None = None,
    page_index: int | None = None,
) -> ExtractStreamEvent[T]:
    return ExtractStreamEvent(
        value=value,
        document_id=doc.document_id,
        raw_text=raw_text,
        flags=tuple(sorted(flags)),
        score=score,
        is_final=is_final,
        result=result,
        attachment_index=attachment_index,
        page_index=page_index,
    )


def _build_partial_stream_event[T](
    *,
    parser: Any,
    doc: Document,
    raw_text: str,
    output_kind: _RootKind | None,
    attachment_index: int | None = None,
    page_index: int | None = None,
) -> ExtractStreamEvent[T] | None:
    try:
        partial = parser.parse_partial()
    except (ValidationError, ValueError, TypeError):
        return None
    if output_kind in {"object", "array"} and "partial_unvalidated" in partial.flags:
        return None
    return _make_stream_event(
        value=partial.value,
        doc=doc,
        raw_text=raw_text,
        flags=partial.flags,
        score=partial.score,
        is_final=False,
        attachment_index=attachment_index,
        page_index=page_index,
    )


def _append_stream_snapshot(
    parser: Any,
    *,
    snapshot: str,
    previous_snapshot: str,
    target: type[Any] | TypeAdapter[Any],
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    max_buffer_chars: int | None,
) -> tuple[Any, str, bool]:
    if snapshot == previous_snapshot:
        return parser, previous_snapshot, False
    if previous_snapshot and snapshot.startswith(previous_snapshot):
        parser.feed(snapshot[len(previous_snapshot) :])
        return parser, snapshot, True
    parser = make_stream_parser(
        target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        max_buffer_chars=max_buffer_chars,
    )
    parser.feed(snapshot)
    return parser, snapshot, True


def _finalize_text_stream_result[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    chunk: TextChunk,
    raw_output: str,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    rendered_prompt_preview: str | None,
) -> ExtractResult[T]:
    state = _DocumentState()
    _accumulate_pass(
        pass_index=0,
        state=state,
        chunks=[chunk],
        inferred=[raw_output],
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )
    return _build_extract_result(
        ctx=ctx,
        doc=doc,
        merged_value=state.merged_value,
        raw_outputs=state.raw_outputs,
        all_flags=state.all_flags,
        worst_score=state.worst_score,
        doc_evidence=state.doc_evidence,
        chunk_debug_entries=state.chunk_debug_entries,
        rendered_prompt_preview=rendered_prompt_preview,
        debug=debug,
        conflicts=state.conflicts,
    )


def _finalize_media_stream_result[T](
    *,
    ctx: _ExtractionContext,
    doc: Document,
    target: type[T] | TypeAdapter[T],
    request: InferenceRequest,
    chunk: MediaChunk,
    raw_output: str,
    parse_options: ParseOptions | None,
    coerce_options: CoerceOptions | None,
    debug: bool,
    rendered_prompt_preview: str | None,
) -> ExtractResult[T]:
    state = _DocumentState()
    _accumulate_media_pass(
        pass_index=0,
        state=state,
        media_requests=[request],
        media_chunks=[chunk],
        inferred=[raw_output],
        target=target,
        ctx=ctx,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
    )
    return _build_extract_result(
        ctx=ctx,
        doc=doc,
        merged_value=state.merged_value,
        raw_outputs=state.raw_outputs,
        all_flags=state.all_flags,
        worst_score=state.worst_score,
        doc_evidence=state.doc_evidence,
        chunk_debug_entries=state.chunk_debug_entries,
        rendered_prompt_preview=rendered_prompt_preview,
        debug=debug,
        conflicts=state.conflicts,
    )


def extract_stream[T](
    text_or_document: str | Document,
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> Iterator[ExtractStreamEvent[T]]:
    ctx = _build_extraction_context(
        text_or_document,
        target,
        model=model,
        prompt=prompt,
        options=options,
        provider_kwargs=provider_kwargs,
    )
    if ctx.opts.passes != 1:
        raise _streaming_mode_error("Set passes=1 when streaming.")

    doc = ctx.documents[0]
    has_media = needs_media(doc.attachments)

    if has_media and ctx.resolved_strategy.plan != "hybrid":
        text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
        if text_doc is not None:
            doc = text_doc
            has_media = False

    native_kwargs: dict[str, Any] = {}
    if ctx.use_native_schema and ctx.target_type is not None:
        native_kwargs["target_type"] = ctx.target_type
        native_kwargs["structured_output"] = ctx.opts.structured_output

    parser = make_stream_parser(
        target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        max_buffer_chars=ctx.opts.max_char_buffer,
    )
    last_snapshot = ""

    if has_media:
        if ctx.resolved_strategy.plan == "hybrid":
            raise _streaming_mode_error("Hybrid mode is not supported yet.")
        if not isinstance(ctx.provider, SupportsMediaInferStream):
            raise TypeError(
                f"Provider {type(ctx.provider).__name__} does not support media streaming"
            )
        _check_media_capability(ctx.provider, is_async=False)
        native_pdf = _provider_supports_native_pdf(ctx.provider)
        _ensure_native_pdf_support(
            ctx.provider,
            doc.attachments,
            media_options=ctx.opts.media,
            branch_label="the current extraction mode",
        )
        media_chunks = chunk_attachments(
            doc.attachments,
            text=doc.text,
            media_options=ctx.opts.media,
            provider_supports_native_pdf=native_pdf,
        )
        strategy = _resolve_page_strategy(ctx.opts.media.page_strategy, media_chunks)
        if strategy != "single":
            raise _streaming_mode_error(
                "Use whole-document mode or page_strategy='single' for streamed PDFs."
            )
        request = _build_single_inference_request(ctx, doc, media_chunks)
        rendered_prompt_preview = request.prompt[:500] if debug else None
        aggregate_chunk = MediaChunk(
            attachment=media_chunks[0].attachment if media_chunks else media_chunks,
            attachment_index=None,
            page_index=None,
            text=media_chunks[0].text if media_chunks else "",
        )
        for snapshot in ctx.provider.infer_media_stream(request, **native_kwargs):
            parser, last_snapshot, changed = _append_stream_snapshot(
                parser,
                snapshot=snapshot,
                previous_snapshot=last_snapshot,
                target=target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                max_buffer_chars=ctx.opts.max_char_buffer,
            )
            if not changed:
                continue
            event = _build_partial_stream_event(
                parser=parser,
                doc=doc,
                raw_text=snapshot,
                output_kind=ctx.output_kind,
                attachment_index=request.attachment_index,
                page_index=request.page_index,
            )
            if event is not None:
                yield event
        final_result = _finalize_media_stream_result(
            ctx=ctx,
            doc=doc,
            target=target,
            request=request,
            chunk=aggregate_chunk,
            raw_output=last_snapshot,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
            rendered_prompt_preview=rendered_prompt_preview,
        )
        yield _make_stream_event(
            value=final_result.value,
            doc=doc,
            raw_text=final_result.raw_text,
            flags=final_result.flags,
            score=final_result.score,
            is_final=True,
            result=final_result,
            attachment_index=request.attachment_index,
            page_index=request.page_index,
        )
        return

    if ctx.resolved_strategy.plan == "hybrid":
        raise _streaming_mode_error("Hybrid mode is not supported yet.")
    if not isinstance(ctx.provider, SupportsInferStream):
        raise TypeError(f"Provider {type(ctx.provider).__name__} does not support streaming")
    chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
    if len(chunk_prompts) != 1:
        raise _streaming_mode_error("Increase max_char_buffer so the document fits in one chunk.")
    rendered_prompt_preview = chunk_prompts[0][:500] if debug else None
    for snapshot in ctx.provider.infer_stream(chunk_prompts[0], **native_kwargs):
        parser, last_snapshot, changed = _append_stream_snapshot(
            parser,
            snapshot=snapshot,
            previous_snapshot=last_snapshot,
            target=target,
            parse_options=parse_options,
            coerce_options=coerce_options,
            max_buffer_chars=ctx.opts.max_char_buffer,
        )
        if not changed:
            continue
        event = _build_partial_stream_event(
            parser=parser,
            doc=doc,
            raw_text=snapshot,
            output_kind=ctx.output_kind,
        )
        if event is not None:
            yield event
    final_result = _finalize_text_stream_result(
        ctx=ctx,
        doc=doc,
        target=target,
        chunk=chunks[0],
        raw_output=last_snapshot,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        rendered_prompt_preview=rendered_prompt_preview,
    )
    yield _make_stream_event(
        value=final_result.value,
        doc=doc,
        raw_text=final_result.raw_text,
        flags=final_result.flags,
        score=final_result.score,
        is_final=True,
        result=final_result,
    )


async def aextract_stream[T](
    text_or_document: str | Document,
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> AsyncIterator[ExtractStreamEvent[T]]:
    ctx = _build_extraction_context(
        text_or_document,
        target,
        model=model,
        prompt=prompt,
        options=options,
        provider_kwargs=provider_kwargs,
    )
    if ctx.opts.passes != 1:
        raise _streaming_mode_error("Set passes=1 when streaming.")

    doc = ctx.documents[0]
    has_media = needs_media(doc.attachments)

    if has_media and ctx.resolved_strategy.plan != "hybrid":
        text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
        if text_doc is not None:
            doc = text_doc
            has_media = False

    native_kwargs: dict[str, Any] = {}
    if ctx.use_native_schema and ctx.target_type is not None:
        native_kwargs["target_type"] = ctx.target_type
        native_kwargs["structured_output"] = ctx.opts.structured_output

    parser = make_stream_parser(
        target,
        parse_options=parse_options,
        coerce_options=coerce_options,
        max_buffer_chars=ctx.opts.max_char_buffer,
    )
    last_snapshot = ""

    if has_media:
        if ctx.resolved_strategy.plan == "hybrid":
            raise _streaming_mode_error("Hybrid mode is not supported yet.")
        if not isinstance(ctx.provider, SupportsAsyncMediaInferStream):
            raise TypeError(
                f"Provider {type(ctx.provider).__name__} does not support async media streaming"
            )
        _check_media_capability(ctx.provider, is_async=True)
        native_pdf = _provider_supports_native_pdf(ctx.provider)
        _ensure_native_pdf_support(
            ctx.provider,
            doc.attachments,
            media_options=ctx.opts.media,
            branch_label="the current extraction mode",
        )
        media_chunks = chunk_attachments(
            doc.attachments,
            text=doc.text,
            media_options=ctx.opts.media,
            provider_supports_native_pdf=native_pdf,
        )
        strategy = _resolve_page_strategy(ctx.opts.media.page_strategy, media_chunks)
        if strategy != "single":
            raise _streaming_mode_error(
                "Use whole-document mode or page_strategy='single' for streamed PDFs."
            )
        request = _build_single_inference_request(ctx, doc, media_chunks)
        rendered_prompt_preview = request.prompt[:500] if debug else None
        aggregate_chunk = MediaChunk(
            attachment=media_chunks[0].attachment if media_chunks else media_chunks,
            attachment_index=None,
            page_index=None,
            text=media_chunks[0].text if media_chunks else "",
        )
        async for snapshot in ctx.provider.ainfer_media_stream(request, **native_kwargs):
            parser, last_snapshot, changed = _append_stream_snapshot(
                parser,
                snapshot=snapshot,
                previous_snapshot=last_snapshot,
                target=target,
                parse_options=parse_options,
                coerce_options=coerce_options,
                max_buffer_chars=ctx.opts.max_char_buffer,
            )
            if not changed:
                continue
            event = _build_partial_stream_event(
                parser=parser,
                doc=doc,
                raw_text=snapshot,
                output_kind=ctx.output_kind,
                attachment_index=request.attachment_index,
                page_index=request.page_index,
            )
            if event is not None:
                yield event
        final_result = _finalize_media_stream_result(
            ctx=ctx,
            doc=doc,
            target=target,
            request=request,
            chunk=aggregate_chunk,
            raw_output=last_snapshot,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
            rendered_prompt_preview=rendered_prompt_preview,
        )
        yield _make_stream_event(
            value=final_result.value,
            doc=doc,
            raw_text=final_result.raw_text,
            flags=final_result.flags,
            score=final_result.score,
            is_final=True,
            result=final_result,
            attachment_index=request.attachment_index,
            page_index=request.page_index,
        )
        return

    if ctx.resolved_strategy.plan == "hybrid":
        raise _streaming_mode_error("Hybrid mode is not supported yet.")
    if not isinstance(ctx.provider, SupportsAsyncInferStream):
        raise TypeError(f"Provider {type(ctx.provider).__name__} does not support async streaming")
    chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
    if len(chunk_prompts) != 1:
        raise _streaming_mode_error("Increase max_char_buffer so the document fits in one chunk.")
    rendered_prompt_preview = chunk_prompts[0][:500] if debug else None
    async for snapshot in ctx.provider.ainfer_stream(chunk_prompts[0], **native_kwargs):
        parser, last_snapshot, changed = _append_stream_snapshot(
            parser,
            snapshot=snapshot,
            previous_snapshot=last_snapshot,
            target=target,
            parse_options=parse_options,
            coerce_options=coerce_options,
            max_buffer_chars=ctx.opts.max_char_buffer,
        )
        if not changed:
            continue
        event = _build_partial_stream_event(
            parser=parser,
            doc=doc,
            raw_text=snapshot,
            output_kind=ctx.output_kind,
        )
        if event is not None:
            yield event
    final_result = _finalize_text_stream_result(
        ctx=ctx,
        doc=doc,
        target=target,
        chunk=chunks[0],
        raw_output=last_snapshot,
        parse_options=parse_options,
        coerce_options=coerce_options,
        debug=debug,
        rendered_prompt_preview=rendered_prompt_preview,
    )
    yield _make_stream_event(
        value=final_result.value,
        doc=doc,
        raw_text=final_result.raw_text,
        flags=final_result.flags,
        score=final_result.score,
        is_final=True,
        result=final_result,
    )


def extract_iter[T](
    text_or_documents: str | Document | Iterable[Document],
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> Iterator[ExtractResult[T]]:
    ctx = _build_extraction_context(
        text_or_documents,
        target,
        model=model,
        prompt=prompt,
        options=options,
        provider_kwargs=provider_kwargs,
    )

    for doc in ctx.documents:
        preflights = _enforce_document_limits(ctx, doc)
        ctx.budget.document_deadline = (
            time.monotonic() + ctx.opts.per_document_timeout_s
            if ctx.opts.per_document_timeout_s is not None
            else None
        )
        has_media = needs_media(doc.attachments)
        if has_media and preflights:
            all_text_only = all(
                preflight.recommended_plan == "text_only" for preflight in preflights.values()
            )
            if (ctx.resolved_strategy.plan == "fused" and all_text_only) or (
                ctx.budget.consecutive_server_errors >= 2
            ):
                text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
                if text_doc is not None:
                    logger.warning("Falling back to text-only extraction for %s", doc.document_id)
                    doc = text_doc
                    has_media = False

        if has_media and ctx.resolved_strategy.plan not in {"hybrid", "fused"}:
            # Try text extraction for PDFs with text layers (pdf_mode=auto)
            text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
            if text_doc is not None:
                doc = text_doc
                has_media = False

        # Build kwargs for native structured output (ignored by non-PydanticAI providers)
        _native_kwargs: dict[str, Any] = {}
        if ctx.use_native_schema and ctx.target_type is not None:
            _native_kwargs["target_type"] = ctx.target_type
            _native_kwargs["structured_output"] = ctx.opts.structured_output

        if has_media:
            _check_media_capability(ctx.provider, is_async=False)
            native_pdf = _provider_supports_native_pdf(ctx.provider)

            if ctx.resolved_strategy.plan == "hybrid":
                page_media_options = ctx.resolved_strategy.page_media or ctx.opts.media
                document_media_options = ctx.resolved_strategy.document_media or ctx.opts.media
                _ensure_native_pdf_support(
                    ctx.provider,
                    doc.attachments,
                    media_options=document_media_options,
                    branch_label="the whole-document branch",
                )
                page_media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=page_media_options,
                    provider_supports_native_pdf=False,
                )
                whole_media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=document_media_options,
                    provider_supports_native_pdf=native_pdf,
                )
                state, rendered_prompt_preview = _run_hybrid_media_extraction(
                    ctx=ctx,
                    doc=doc,
                    page_media_chunks=page_media_chunks,
                    whole_media_chunks=whole_media_chunks,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            elif ctx.resolved_strategy.plan == "fused":
                preflight_map = dict(preflights)
                media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=ctx.opts.media,
                    provider_supports_native_pdf=False,
                    fused=True,
                    preflight_results=preflight_map,
                )
                media_chunks = _resolve_fused_media_chunks(doc, media_chunks)
                state, rendered_prompt_preview = _run_fused_media_extraction(
                    ctx=ctx,
                    doc=doc,
                    media_chunks=media_chunks,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            else:
                _ensure_native_pdf_support(
                    ctx.provider,
                    doc.attachments,
                    media_options=ctx.opts.media,
                    branch_label="the current extraction mode",
                )
                media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=ctx.opts.media,
                    provider_supports_native_pdf=native_pdf,
                )
                strategy = _resolve_page_strategy(
                    ctx.opts.media.page_strategy, media_chunks, preflights=preflights
                )
                state = _DocumentState()
                batch_length = max(1, ctx.opts.batch_length)
                max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))

                if strategy == "single":
                    single_req = _build_single_inference_request(ctx, doc, media_chunks)
                    rendered_prompt_preview = single_req.prompt[:500] if debug else None
                    inferred = _infer_media_batch(
                        ctx, ctx.provider, [single_req], batch_length, **_native_kwargs
                    )
                    aggregate_chunk = MediaChunk(
                        attachment=media_chunks[0].attachment,
                        attachment_index=None,
                        page_index=None,
                        text=media_chunks[0].text,
                        mode=media_chunks[0].mode,
                    )
                    _accumulate_media_pass(
                        pass_index=0,
                        state=state,
                        media_requests=[single_req],
                        media_chunks=[aggregate_chunk],
                        inferred=inferred,
                        target=target,
                        ctx=ctx,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                    )
                    _run_media_targeted_repair(
                        ctx=ctx,
                        doc=doc,
                        state=state,
                        request=single_req,
                        chunk=aggregate_chunk,
                        original_prompt=single_req.prompt,
                        target=target,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                        native_kwargs=_native_kwargs,
                    )
                else:
                    media_requests = _build_media_inference_requests(ctx, doc, media_chunks)
                    rendered_prompt_preview = (
                        media_requests[0].prompt[:500] if (debug and media_requests) else None
                    )
                    if max_workers <= 1:
                        inferred = _infer_media_batch(
                            ctx, ctx.provider, media_requests, batch_length, **_native_kwargs
                        )
                    else:
                        inferred = _infer_media_batch_parallel(
                            ctx,
                            ctx.provider,
                            media_requests,
                            batch_length,
                            max_workers,
                            **_native_kwargs,
                        )
                    _accumulate_media_pass(
                        pass_index=0,
                        state=state,
                        media_requests=media_requests,
                        media_chunks=media_chunks,
                        inferred=inferred,
                        target=target,
                        ctx=ctx,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                    )
                    single_req = _build_single_inference_request(ctx, doc, media_chunks)
                    aggregate_chunk = MediaChunk(
                        attachment=media_chunks[0].attachment,
                        attachment_index=None,
                        page_index=None,
                        text=doc.text,
                        mode=media_chunks[0].mode,
                    )
                    _run_media_targeted_repair(
                        ctx=ctx,
                        doc=doc,
                        state=state,
                        request=single_req,
                        chunk=aggregate_chunk,
                        original_prompt=single_req.prompt,
                        target=target,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                        native_kwargs=_native_kwargs,
                    )
        else:
            if ctx.resolved_strategy.plan == "hybrid":
                state, rendered_prompt_preview = _run_hybrid_text_extraction(
                    ctx=ctx,
                    doc=doc,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            else:
                chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
                rendered_prompt_preview = (
                    chunk_prompts[0][:500] if (debug and chunk_prompts) else None
                )
                state = _DocumentState()
                batch_length = max(1, ctx.opts.batch_length)
                max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))

                if max_workers <= 1:
                    inferred = _infer_batch(
                        ctx, ctx.provider, chunk_prompts, batch_length, **_native_kwargs
                    )
                else:
                    inferred = _infer_batch_parallel(
                        ctx,
                        ctx.provider,
                        chunk_prompts,
                        batch_length,
                        max_workers,
                        **_native_kwargs,
                    )
                _accumulate_pass(
                    pass_index=0,
                    state=state,
                    chunks=chunks,
                    inferred=inferred,
                    target=target,
                    ctx=ctx,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                )
                _run_text_targeted_repair(
                    ctx=ctx,
                    doc=doc,
                    state=state,
                    original_prompt=_build_single_text_prompt(ctx, doc),
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )

        yield _build_extract_result(
            ctx=ctx,
            doc=doc,
            merged_value=state.merged_value,
            raw_outputs=state.raw_outputs,
            all_flags=state.all_flags,
            worst_score=state.worst_score,
            doc_evidence=state.doc_evidence,
            chunk_debug_entries=state.chunk_debug_entries,
            rendered_prompt_preview=rendered_prompt_preview,
            debug=debug,
            conflicts=state.conflicts,
            diagnostics=state.diagnostics,
        )


async def _ainfer_batch(
    ctx: _ExtractionContext,
    provider: Any,
    prompts: Sequence[str],
    batch_length: int,
    **infer_kwargs: Any,
) -> list[str]:
    """Async version of batched inference (sequential)."""
    all_outputs: list[str] = []
    for i in range(0, len(prompts), batch_length):
        batch = prompts[i : i + batch_length]
        _check_document_deadline(ctx)
        ctx.budget.ensure_api_budget(1)
        try:
            if hasattr(provider, "ainfer"):
                outputs = await _run_async_call_with_timeout(
                    lambda batch=batch: provider.ainfer(batch, **infer_kwargs),
                    timeout_s=ctx.opts.per_call_timeout_s,
                )
            else:
                outputs = await asyncio.to_thread(
                    lambda batch=batch: _run_sync_call_with_timeout(
                        lambda: provider.infer(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                )
            outputs = normalize_text_outputs(
                outputs,
                expected_count=len(batch),
                context="provider.ainfer" if hasattr(provider, "ainfer") else "provider.infer",
            )
            _record_inference_success(ctx)
        except Exception as exc:
            _maybe_handle_provider_error(ctx, exc)
            raise
        all_outputs.extend(outputs)
    return all_outputs


async def _ainfer_batch_parallel(
    ctx: _ExtractionContext,
    provider: Any,
    prompts: Sequence[str],
    batch_length: int,
    max_workers: int,
    **infer_kwargs: Any,
) -> list[str]:
    """Async version of batched inference with concurrency via asyncio.gather."""
    batch_length = max(1, batch_length)
    batches: list[Sequence[str]] = []
    for i in range(0, len(prompts), batch_length):
        batches.append(prompts[i : i + batch_length])
    max_workers = max(1, min(max_workers, ctx.budget.network_workers))
    logger.debug(
        "Async parallel batched inference: %d batches, max_workers=%d", len(batches), max_workers
    )

    sem = asyncio.Semaphore(max_workers)

    async def _run_batch(batch: Sequence[str]) -> list[str]:
        async with sem:
            _check_document_deadline(ctx)
            try:
                if hasattr(provider, "ainfer"):
                    raw = await _run_async_call_with_timeout(
                        lambda: provider.ainfer(batch, **infer_kwargs),
                        timeout_s=ctx.opts.per_call_timeout_s,
                    )
                else:
                    raw = await asyncio.to_thread(
                        lambda: _run_sync_call_with_timeout(
                            lambda: provider.infer(batch, **infer_kwargs),
                            timeout_s=ctx.opts.per_call_timeout_s,
                        )
                    )
                normalized = normalize_text_outputs(
                    raw,
                    expected_count=len(batch),
                    context="provider.ainfer" if hasattr(provider, "ainfer") else "provider.infer",
                )
                _record_inference_success(ctx)
                return normalized
            except Exception as exc:
                _maybe_handle_provider_error(ctx, exc)
                raise

    ctx.budget.ensure_api_budget(len(batches))
    results = await asyncio.gather(*[_run_batch(b) for b in batches])

    all_outputs: list[str] = []
    for batch_result in results:
        all_outputs.extend(batch_result)
    return all_outputs


async def extract_aiter[T](
    text_or_documents: str | Document | Iterable[Document],
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> AsyncGenerator[ExtractResult[T], None]:
    ctx = _build_extraction_context(
        text_or_documents,
        target,
        model=model,
        prompt=prompt,
        options=options,
        provider_kwargs=provider_kwargs,
    )

    for doc in ctx.documents:
        preflights = _enforce_document_limits(ctx, doc)
        ctx.budget.document_deadline = (
            time.monotonic() + ctx.opts.per_document_timeout_s
            if ctx.opts.per_document_timeout_s is not None
            else None
        )
        has_media = needs_media(doc.attachments)
        if has_media and preflights:
            all_text_only = all(
                preflight.recommended_plan == "text_only" for preflight in preflights.values()
            )
            if (ctx.resolved_strategy.plan == "fused" and all_text_only) or (
                ctx.budget.consecutive_server_errors >= 2
            ):
                text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
                if text_doc is not None:
                    logger.warning("Falling back to text-only extraction for %s", doc.document_id)
                    doc = text_doc
                    has_media = False

        if has_media and ctx.resolved_strategy.plan not in {"hybrid", "fused"}:
            text_doc = _try_pdf_text_extraction(doc, ctx.opts.media)
            if text_doc is not None:
                doc = text_doc
                has_media = False

        # Build kwargs for native structured output (ignored by non-PydanticAI providers)
        _native_kwargs: dict[str, Any] = {}
        if ctx.use_native_schema and ctx.target_type is not None:
            _native_kwargs["target_type"] = ctx.target_type
            _native_kwargs["structured_output"] = ctx.opts.structured_output

        if has_media:
            _check_media_capability(ctx.provider, is_async=True)
            native_pdf = _provider_supports_native_pdf(ctx.provider)

            if ctx.resolved_strategy.plan == "hybrid":
                page_media_options = ctx.resolved_strategy.page_media or ctx.opts.media
                document_media_options = ctx.resolved_strategy.document_media or ctx.opts.media
                _ensure_native_pdf_support(
                    ctx.provider,
                    doc.attachments,
                    media_options=document_media_options,
                    branch_label="the whole-document branch",
                )
                page_media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=page_media_options,
                    provider_supports_native_pdf=False,
                )
                whole_media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=document_media_options,
                    provider_supports_native_pdf=native_pdf,
                )
                state, rendered_prompt_preview = await _arun_hybrid_media_extraction(
                    ctx=ctx,
                    doc=doc,
                    page_media_chunks=page_media_chunks,
                    whole_media_chunks=whole_media_chunks,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            elif ctx.resolved_strategy.plan == "fused":
                preflight_map = dict(preflights)
                media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=ctx.opts.media,
                    provider_supports_native_pdf=False,
                    fused=True,
                    preflight_results=preflight_map,
                )
                media_chunks = _resolve_fused_media_chunks(doc, media_chunks)
                state, rendered_prompt_preview = await _arun_fused_media_extraction(
                    ctx=ctx,
                    doc=doc,
                    media_chunks=media_chunks,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            else:
                _ensure_native_pdf_support(
                    ctx.provider,
                    doc.attachments,
                    media_options=ctx.opts.media,
                    branch_label="the current extraction mode",
                )
                media_chunks = chunk_attachments(
                    doc.attachments,
                    text=doc.text,
                    media_options=ctx.opts.media,
                    provider_supports_native_pdf=native_pdf,
                )
                strategy = _resolve_page_strategy(
                    ctx.opts.media.page_strategy, media_chunks, preflights=preflights
                )
                state = _DocumentState()
                batch_length = max(1, ctx.opts.batch_length)
                max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))

                if strategy == "single":
                    single_req = _build_single_inference_request(ctx, doc, media_chunks)
                    rendered_prompt_preview = single_req.prompt[:500] if debug else None
                    inferred = await _ainfer_media_batch(
                        ctx, ctx.provider, [single_req], batch_length, **_native_kwargs
                    )
                    aggregate_chunk = MediaChunk(
                        attachment=media_chunks[0].attachment,
                        attachment_index=None,
                        page_index=None,
                        text=media_chunks[0].text,
                        mode=media_chunks[0].mode,
                    )
                    _accumulate_media_pass(
                        pass_index=0,
                        state=state,
                        media_requests=[single_req],
                        media_chunks=[aggregate_chunk],
                        inferred=inferred,
                        target=target,
                        ctx=ctx,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                    )
                    await _arun_media_targeted_repair(
                        ctx=ctx,
                        doc=doc,
                        state=state,
                        request=single_req,
                        chunk=aggregate_chunk,
                        original_prompt=single_req.prompt,
                        target=target,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                        native_kwargs=_native_kwargs,
                    )
                else:
                    media_requests = _build_media_inference_requests(ctx, doc, media_chunks)
                    rendered_prompt_preview = (
                        media_requests[0].prompt[:500] if (debug and media_requests) else None
                    )
                    if max_workers <= 1:
                        inferred = await _ainfer_media_batch(
                            ctx, ctx.provider, media_requests, batch_length, **_native_kwargs
                        )
                    else:
                        inferred = await _ainfer_media_batch_parallel(
                            ctx,
                            ctx.provider,
                            media_requests,
                            batch_length,
                            max_workers,
                            **_native_kwargs,
                        )
                    _accumulate_media_pass(
                        pass_index=0,
                        state=state,
                        media_requests=media_requests,
                        media_chunks=media_chunks,
                        inferred=inferred,
                        target=target,
                        ctx=ctx,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                    )
                    single_req = _build_single_inference_request(ctx, doc, media_chunks)
                    aggregate_chunk = MediaChunk(
                        attachment=media_chunks[0].attachment,
                        attachment_index=None,
                        page_index=None,
                        text=doc.text,
                        mode=media_chunks[0].mode,
                    )
                    await _arun_media_targeted_repair(
                        ctx=ctx,
                        doc=doc,
                        state=state,
                        request=single_req,
                        chunk=aggregate_chunk,
                        original_prompt=single_req.prompt,
                        target=target,
                        parse_options=parse_options,
                        coerce_options=coerce_options,
                        debug=debug,
                        native_kwargs=_native_kwargs,
                    )
        else:
            if ctx.resolved_strategy.plan == "hybrid":
                state, rendered_prompt_preview = await _arun_hybrid_text_extraction(
                    ctx=ctx,
                    doc=doc,
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )
            else:
                chunks, chunk_prompts = _prepare_document_chunks_and_prompts(ctx, doc)
                rendered_prompt_preview = (
                    chunk_prompts[0][:500] if (debug and chunk_prompts) else None
                )
                state = _DocumentState()
                batch_length = max(1, ctx.opts.batch_length)
                max_workers = max(1, min(ctx.opts.max_workers, ctx.budget.network_workers))

                if max_workers <= 1:
                    inferred = await _ainfer_batch(
                        ctx, ctx.provider, chunk_prompts, batch_length, **_native_kwargs
                    )
                else:
                    inferred = await _ainfer_batch_parallel(
                        ctx,
                        ctx.provider,
                        chunk_prompts,
                        batch_length,
                        max_workers,
                        **_native_kwargs,
                    )
                _accumulate_pass(
                    pass_index=0,
                    state=state,
                    chunks=chunks,
                    inferred=inferred,
                    target=target,
                    ctx=ctx,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                )
                await _arun_text_targeted_repair(
                    ctx=ctx,
                    doc=doc,
                    state=state,
                    original_prompt=_build_single_text_prompt(ctx, doc),
                    target=target,
                    parse_options=parse_options,
                    coerce_options=coerce_options,
                    debug=debug,
                    native_kwargs=_native_kwargs,
                )

        yield _build_extract_result(
            ctx=ctx,
            doc=doc,
            merged_value=state.merged_value,
            raw_outputs=state.raw_outputs,
            all_flags=state.all_flags,
            worst_score=state.worst_score,
            doc_evidence=state.doc_evidence,
            chunk_debug_entries=state.chunk_debug_entries,
            rendered_prompt_preview=rendered_prompt_preview,
            debug=debug,
            conflicts=state.conflicts,
            diagnostics=state.diagnostics,
        )


async def aextract[T](
    text_or_documents: str | Document | Iterable[Document],
    target: type[T] | TypeAdapter[T],
    *,
    model: str | Any | None = None,
    prompt: Prompt | str | None = None,
    options: ExtractOptions | None = None,
    provider_kwargs: dict[str, Any] | None = None,
    parse_options: ParseOptions | None = None,
    coerce_options: CoerceOptions | None = None,
    debug: bool = False,
) -> ExtractResult[T] | list[ExtractResult[T]]:
    results = [
        r
        async for r in extract_aiter(
            text_or_documents,
            target,
            model=model,
            prompt=prompt,
            options=options,
            provider_kwargs=provider_kwargs,
            parse_options=parse_options,
            coerce_options=coerce_options,
            debug=debug,
        )
    ]
    if isinstance(text_or_documents, (str, Document)):
        return results[0]
    return results
