"""L2/L3 보강 파이프라인 전용 엔진."""

from __future__ import annotations

from concurrent.futures import CancelledError, ThreadPoolExecutor
import os
import queue
import threading
import time
from typing import Callable

from solidlsp.ls_config import Language

from sari.core.models import L4AdmissionDecisionDTO, L5ReasonCode, L5RejectReason
from sari.core.language.registry import get_enabled_language_names, resolve_language_from_path
from sari.core.models import (
    CollectedFileBodyDTO,
    FileEnrichFailureUpdateDTO,
    FileEnrichJobDTO,
    ToolReadinessStateDTO,
    now_iso8601_utc,
)
from sari.services.collection.l5.l5_admission_policy import LanguageL5Policy, TokenBucket
from sari.services.collection.l5.l5_admission_runtime_service import (
    L5AdmissionRuntimeService,
    L5AdmissionRuntimeState,
)
from sari.services.collection.l5.l5_default_policy_builder import build_default_language_policy_map
from sari.services.collection.l5.l5_runtime_stats_service import L5RuntimeStatsService
from sari.db.repositories.tool_data_layer_repository import ToolDataLayerRepository
from sari.services.collection.error_policy import CollectionErrorPolicy
from sari.services.collection.l3.l3_asset_loader import L3AssetLoader
from sari.services.collection.l3.l3_orchestrator import L3Orchestrator
from sari.services.collection.l3.l3_skip_runtime_service import L3SkipRuntimeService
from sari.services.collection.l3.l3_runtime_coordination_service import L3RuntimeCoordinationService
from sari.services.collection.l3.l3_scheduling_service import L3SchedulingService
from sari.services.collection.l3.l3_error_handling_service import L3ErrorHandlingService
from sari.services.collection.l3.l3_bootstrap_mode_service import L3BootstrapModeService
from sari.services.collection.enrich_runtime_service_registry import EnrichRuntimeServiceRegistry
from sari.services.collection.l3.l3_language_config_parser import (
    parse_l3_supported_languages as _parse_l3_supported_languages_shared,
    parse_lsp_probe_l1_languages as _parse_lsp_probe_l1_languages_shared,
)
from sari.services.collection.l3.l3_failure_classifier import (
    classify_l3_extract_failure_kind,
    extract_error_code_from_lsp_error_message,
    is_scope_escalation_trigger_error_for_l3,
    next_scope_level_for_l3_escalation,
)
from sari.services.collection.enrich_engine_wiring import wire_engine_services, wire_runtime_processors
from sari.services.collection.enrich_result_dto import (
    _L2ResultBuffersDTO,
    _L3JobResultDTO,
    _L3ResultBuffersDTO,
    _LayerUpsertBucketsDTO,
    _LayerUpsertsDTO,
)
from sari.services.collection.l3.l3_group_processor import L3GroupProcessor as _L3GroupProcessor
from sari.services.collection.l3.l3_treesitter_preprocess_service import (
    L3PreprocessDecision,
    L3PreprocessResultDTO,
)
from sari.services.collection.perf_trace import PerfTracer


def _default_l3_executor_max_workers() -> int:
    """L3 전역 executor 기본 병렬도를 안정성 우선으로 계산한다."""
    cpu_count = os.cpu_count() or 4
    return max(2, min(8, int(cpu_count)))


class EnrichEngine:
    """파일 보강(L2/L3) 처리와 bootstrap 모드를 관리한다."""

    def __init__(
        self,
        *,
        file_repo: object,
        enrich_queue_repo: object,
        body_repo: object,
        lsp_repo: object,
        readiness_repo: object,
        policy: object,
        lsp_backend: object,
        policy_repo: object | None,
        event_repo: object | None,
        vector_index_sink: object | None,
        tool_layer_repo: ToolDataLayerRepository | None,
        run_mode: str,
        persist_body_for_read: bool,
        l3_ready_queue: queue.Queue[FileEnrichJobDTO],
        error_policy: CollectionErrorPolicy,
        record_enrich_latency: Callable[[float], None],
        assert_parent_alive: Callable[[str], None],
        flush_batch_size: int,
        flush_interval_sec: float,
        flush_max_body_bytes: int,
        l3_parallel_enabled: bool,
        l3_executor_max_workers: int,
        l3_recent_success_ttl_sec: int,
        l3_backpressure_on_interactive: bool,
        l3_backpressure_cooldown_ms: int,
        l3_supported_languages: tuple[str, ...],
        lsp_probe_l1_languages: tuple[str, ...],
        l5_admission_shadow_enabled: bool = False,
        l5_admission_enforced: bool = False,
        l5_call_rate_total_max: float = 0.05,
        l5_call_rate_batch_max: float = 0.01,
        l5_calls_per_min_per_lang_max: int = 30,
        l5_tokens_per_10sec_global_max: int = 120,
        l5_tokens_per_10sec_per_lang_max: int = 30,
        l5_tokens_per_10sec_per_workspace_max: int = 20,
        l5_min_defer_sec: int = 5,
        l3_query_compile_ms_budget: float = 10.0,
        l3_query_budget_ms: float = 30.0,
        l3_tree_sitter_executor_mode: str = "inline",
        l3_tree_sitter_subinterp_workers: int = 4,
        l3_tree_sitter_subinterp_min_bytes: int = 4096,
        l5_db_short_circuit_enabled: bool = True,
        event_bus: object = None,
    ) -> None:
        """엔진 실행에 필요한 의존성을 주입받는다."""
        self._file_repo = file_repo
        self._enrich_queue_repo = enrich_queue_repo
        self._body_repo = body_repo
        self._lsp_repo = lsp_repo
        self._readiness_repo = readiness_repo
        self._policy = policy
        self._lsp_backend = lsp_backend
        self._policy_repo = policy_repo
        self._event_repo = event_repo
        self._vector_index_sink = vector_index_sink
        self._tool_layer_repo = tool_layer_repo
        self._run_mode = "prod" if run_mode == "prod" else "dev"
        self._persist_body_for_read = persist_body_for_read
        self._l3_ready_queue = l3_ready_queue
        self._error_policy = error_policy
        self._record_enrich_latency = record_enrich_latency
        self._assert_parent_alive = assert_parent_alive
        self._flush_batch_size = flush_batch_size
        self._flush_interval_sec = flush_interval_sec
        self._flush_max_body_bytes = flush_max_body_bytes
        self._l3_parallel_enabled = bool(l3_parallel_enabled)
        self._l3_executor_max_workers = (
            max(1, int(l3_executor_max_workers))
            if int(l3_executor_max_workers) > 0
            else _default_l3_executor_max_workers()
        )
        self._l3_group_wait_timeout_sec = 90.0
        self._l5_batch_wait_timeout_sec = 90.0
        self._l3_recent_success_ttl_sec = max(0, int(l3_recent_success_ttl_sec))
        self._l3_backpressure_on_interactive = bool(l3_backpressure_on_interactive)
        self._l3_backpressure_cooldown_sec = max(0.01, float(max(10, int(l3_backpressure_cooldown_ms))) / 1000.0)
        self._l3_executor = ThreadPoolExecutor(max_workers=self._l3_executor_max_workers, thread_name_prefix="enrich-l3")
        self._l3_executor_closed = False
        self._l5_detached_futures: dict[object, FileEnrichJobDTO] = {}
        self._l5_detached_lock = threading.Lock()
        self._indexing_mode = "steady"
        self._bootstrap_started_at = time.monotonic()
        self._l3_supported_languages = self._parse_l3_supported_languages(l3_supported_languages)
        self._lsp_probe_l1_languages = self._parse_lsp_probe_l1_languages(lsp_probe_l1_languages)
        self._perf_tracer = PerfTracer(component="enrich_engine")
        self._l5_admission_shadow_enabled = bool(l5_admission_shadow_enabled)
        self._l5_admission_enforced = bool(l5_admission_enforced)
        self._l5_total_decisions = 0
        self._l5_total_admitted = 0
        self._l5_batch_decisions = 0
        self._l5_batch_admitted = 0
        self._l5_calls_per_min_per_lang_max = max(1, int(l5_calls_per_min_per_lang_max))
        self._l5_call_rate_total_max = max(0.0, min(1.0, float(l5_call_rate_total_max)))
        self._l5_call_rate_batch_max = max(0.0, min(1.0, float(l5_call_rate_batch_max)))
        self._l5_tokens_per_10sec_global_max = max(1, int(l5_tokens_per_10sec_global_max))
        self._l5_tokens_per_10sec_per_lang_max = max(1, int(l5_tokens_per_10sec_per_lang_max))
        self._l5_tokens_per_10sec_per_workspace_max = max(1, int(l5_tokens_per_10sec_per_workspace_max))
        self._l5_min_defer_sec = max(0, int(l5_min_defer_sec))
        self._l5_db_short_circuit_enabled = bool(l5_db_short_circuit_enabled)
        self._event_bus = event_bus
        self._l3_asset_loader = L3AssetLoader()
        self._runtime_services = EnrichRuntimeServiceRegistry(self)
        self._l3_bootstrap_mode_service = L3BootstrapModeService(
            file_repo=self._file_repo,
            policy_repo=self._policy_repo,
        )
        self._extract_error_code_fn = extract_error_code_from_lsp_error_message
        self._is_scope_escalation_trigger_fn = (
            lambda code, message: is_scope_escalation_trigger_error_for_l3(code=code, message=message)
        )
        self._next_scope_level_for_escalation_fn = next_scope_level_for_l3_escalation
        self._classify_failure_kind_fn = classify_l3_extract_failure_kind
        wire_engine_services(
            self,
            l3_query_compile_ms_budget=l3_query_compile_ms_budget,
            l3_query_budget_ms=l3_query_budget_ms,
            l3_tree_sitter_executor_mode=l3_tree_sitter_executor_mode,
            l3_tree_sitter_subinterp_workers=l3_tree_sitter_subinterp_workers,
            l3_tree_sitter_subinterp_min_bytes=l3_tree_sitter_subinterp_min_bytes,
        )
        self._initialize_runtime_processors()

    def _initialize_runtime_processors(self) -> None:
        """런타임 processor/coordinator wiring을 구성한다."""
        wire_runtime_processors(self)

    def _build_default_language_policy_map(self) -> dict[str, LanguageL5Policy]:
        """전 언어를 열어두고 예산으로 조이는 기본 L5 정책을 생성한다."""
        return build_default_language_policy_map(get_enabled_language_names())

    def shutdown(self) -> None:
        """L3 전역 executor를 종료한다."""
        preprocess_service = getattr(self, "_l3_preprocess_service", None)
        if preprocess_service is not None:
            shutdown = getattr(preprocess_service, "shutdown", None)
            if callable(shutdown):
                try:
                    shutdown()
                except (RuntimeError, OSError, ValueError, TypeError):
                    ...
        if self._l3_executor_closed:
            return
        self._l3_executor.shutdown(wait=True)
        self._l3_executor_closed = True

    def reset_runtime_state(self) -> None:
        """백그라운드 시작 시 엔진 상태를 초기화한다."""
        self._bootstrap_started_at = time.monotonic()
        self._indexing_mode = "steady"
        reset = self._get_or_init_l5_runtime_stats_service().reset_runtime_state(
            reject_counts=self._l5_reject_counts_by_reason,
            cost_units_by_reason=self._l5_cost_units_by_reason,
            cost_units_by_language=self._l5_cost_units_by_language,
            cost_units_by_workspace=self._l5_cost_units_by_workspace,
            admitted_timestamps_by_lang=self._l5_admitted_timestamps_by_lang,
            cooldown_until_by_scope_file=self._l5_cooldown_until_by_scope_file,
        )
        self._l5_total_decisions = int(reset["l5_total_decisions"])
        self._l5_total_admitted = int(reset["l5_total_admitted"])
        self._l5_batch_decisions = int(reset["l5_batch_decisions"])
        self._l5_batch_admitted = int(reset["l5_batch_admitted"])

    def get_runtime_metrics(self) -> dict[str, float]:
        """L4/L5 admission 관련 런타임 메트릭을 반환한다."""
        reject_counts = self._get_or_init_l5_reject_counts()
        cost_by_reason = self._get_or_init_l5_cost_units_by_reason()
        cost_by_language = self._get_or_init_l5_cost_units_by_language()
        cost_by_workspace = self._get_or_init_l5_cost_units_by_workspace()
        metrics = self._get_or_init_l5_runtime_stats_service().build_metrics(
            total_decisions=int(self._l5_total_decisions),
            total_admitted=int(self._l5_total_admitted),
            batch_decisions=int(self._l5_batch_decisions),
            batch_admitted=int(self._l5_batch_admitted),
            reject_counts=reject_counts,
            cost_units_by_reason=cost_by_reason,
            cost_units_by_language=cost_by_language,
            cost_units_by_workspace=cost_by_workspace,
        )
        cache_service = getattr(self, "_l5_cached_extract_service", None)
        if cache_service is not None and hasattr(cache_service, "get_metrics"):
            try:
                cache_metrics = cache_service.get_metrics()
            except (RuntimeError, OSError, ValueError, TypeError):
                cache_metrics = {}
            if isinstance(cache_metrics, dict):
                metrics.update({str(k): float(v) for k, v in cache_metrics.items()})
        return metrics

    def get_l3_quality_shadow_summary(self) -> dict[str, object]:
        """L3 AST 품질 shadow 비교 요약을 반환한다 (Phase A, metrics-only)."""
        orchestrator = getattr(self, "_l3_orchestrator", None)
        if orchestrator is None:
            return {"enabled": False, "sampled_files": 0, "shadow_eval_errors": 0}
        getter = getattr(orchestrator, "get_quality_shadow_summary", None)
        if not callable(getter):
            return {"enabled": False, "sampled_files": 0, "shadow_eval_errors": 0}
        try:
            summary = getter()
        except (RuntimeError, OSError, ValueError, TypeError):
            return {"enabled": False, "sampled_files": 0, "shadow_eval_errors": 0}
        if not isinstance(summary, dict):
            return {"enabled": False, "sampled_files": 0, "shadow_eval_errors": 0}
        return dict(summary)

    def get_l3_quality_shadow_mode(self) -> dict[str, object]:
        """L3 quality shadow runtime 설정값을 반환한다."""
        orchestrator = getattr(self, "_l3_orchestrator", None)
        getter = getattr(orchestrator, "get_quality_shadow_mode", None)
        if not callable(getter):
            return {"enabled": False, "sample_rate": 0.0, "max_files": 0, "lang_allowlist": ()}
        try:
            mode = getter()
        except (RuntimeError, OSError, ValueError, TypeError):
            return {"enabled": False, "sample_rate": 0.0, "max_files": 0, "lang_allowlist": ()}
        if not isinstance(mode, dict):
            return {"enabled": False, "sample_rate": 0.0, "max_files": 0, "lang_allowlist": ()}
        return {
            "enabled": bool(mode.get("enabled", False)),
            "sample_rate": float(mode.get("sample_rate", 0.0)),
            "max_files": int(mode.get("max_files", 0)),
            "lang_allowlist": tuple(
                str(item)
                for item in mode.get("lang_allowlist", ())
                if str(item).strip() != ""
            ),
        }

    def set_l5_admission_mode(self, *, shadow_enabled: bool, enforced: bool) -> None:
        """L5 admission 모드를 런타임에서 동적으로 갱신한다."""
        self._l5_admission_shadow_enabled = bool(shadow_enabled)
        self._l5_admission_enforced = bool(enforced)
        self._l3_orchestrator.set_l5_admission_mode(
            evaluate_l5_admission=(self._evaluate_l5_admission_for_job if self._l5_admission_shadow_enabled else None),
            enforced=self._l5_admission_enforced,
        )

    def set_l3_quality_shadow_mode(
        self,
        *,
        enabled: bool,
        sample_rate: float,
        max_files: int,
        lang_allowlist: tuple[str, ...],
    ) -> None:
        """L3 quality shadow 모드를 런타임에서 동적으로 갱신한다."""
        orchestrator = getattr(self, "_l3_orchestrator", None)
        setter = getattr(orchestrator, "set_quality_shadow_mode", None)
        if not callable(setter):
            return
        setter(
            enabled=enabled,
            sample_rate=sample_rate,
            max_files=max_files,
            lang_allowlist=lang_allowlist,
        )

    def indexing_mode(self) -> str:
        """현재 인덱싱 모드를 반환한다."""
        return self._indexing_mode

    def process_enrich_jobs(self, limit: int) -> int:
        """L2/L3 통합 보강 작업을 수행한다."""
        return self._enrich_jobs_processor.process_jobs(limit=limit)

    def process_enrich_jobs_l2(self, limit: int) -> int:
        """L2 전용 보강 처리."""
        return self._l2_job_processor.process_jobs(limit=limit)

    def process_enrich_jobs_l3(self, limit: int) -> int:
        """L3 전용 보강 처리."""
        batch_started_at = time.perf_counter()
        self._assert_parent_alive("enrich_worker_l3")
        acquire_started_at = time.perf_counter()
        jobs = self._acquire_l3_jobs(limit=limit)
        acquire_elapsed_ms = (time.perf_counter() - acquire_started_at) * 1000.0
        rebalance_started_at = time.perf_counter()
        jobs = self._rebalance_jobs_by_language(jobs=jobs)
        rebalance_elapsed_ms = (time.perf_counter() - rebalance_started_at) * 1000.0
        processed = 0
        l3_buffers = _L3ResultBuffersDTO.empty()
        body_upserts: list[CollectedFileBodyDTO] = []
        last_flush_at = time.perf_counter()
        flush_count = 0
        group_count = 0
        grouped_jobs = self._group_jobs_by_repo_and_language(jobs=jobs)
        grouped_jobs = self._order_l3_groups_for_scheduling(groups=grouped_jobs)
        for group in grouped_jobs:
            group_count += 1
            processed += self._process_l3_group(group=group, buffers=l3_buffers, body_upserts=body_upserts)
            should_flush_by_size = len(l3_buffers.done_ids) + len(l3_buffers.failed_updates) >= self._flush_batch_size
            should_flush_by_time = time.perf_counter() - last_flush_at >= self._flush_interval_sec
            if should_flush_by_size or should_flush_by_time:
                with self._perf_tracer.span("process_enrich_jobs_l3.flush_buffers", phase="l3_flush"):
                    self._l3_flush_coordinator.flush(buffers=l3_buffers, body_upserts=body_upserts)
                flush_count += 1
                last_flush_at = time.perf_counter()
        with self._perf_tracer.span("process_enrich_jobs_l3.flush_buffers_final", phase="l3_flush"):
            self._l3_flush_coordinator.flush(buffers=l3_buffers, body_upserts=body_upserts)
        flush_count += 1
        return processed

    def process_enrich_jobs_l5(self, limit: int) -> int:
        """L5 lane 전용 보강 처리 (병렬)."""
        from concurrent.futures import FIRST_COMPLETED, wait
        self._assert_parent_alive("enrich_worker_l5")
        processed = 0
        l5_buffers = _L3ResultBuffersDTO.empty()
        body_upserts: list[CollectedFileBodyDTO] = []
        last_flush_at = time.perf_counter()
        processed += self._collect_completed_detached_l5_futures(l5_buffers=l5_buffers)
        jobs = self._enrich_queue_repo.acquire_pending_for_l5(limit=limit, now_iso=now_iso8601_utc())
        jobs = self._rebalance_jobs_by_language(jobs=jobs)
        if not jobs:
            if len(l5_buffers.done_ids) > 0 or len(l5_buffers.failed_updates) > 0:
                self._l3_flush_coordinator.flush(buffers=l5_buffers, body_upserts=body_upserts)
            return processed
        futures = {self._l3_executor.submit(self._process_single_l5_job, job): job for job in jobs}
        timeout_sec = max(1.0, float(getattr(self, "_l5_batch_wait_timeout_sec", 90.0)))
        submitted_at = {future: time.perf_counter() for future in futures.keys()}
        started_at: dict[object, float] = {}
        pending = set(futures.keys())
        while pending:
            done, pending = wait(
                pending,
                timeout=0.5,
                return_when=FIRST_COMPLETED,
            )
            now = time.perf_counter()
            running_state: dict[object, bool] = {}
            for future in list(pending):
                running = getattr(future, "running", None)
                is_running = bool(running()) if callable(running) else False
                running_state[future] = is_running
                if is_running and future not in started_at:
                    started_at[future] = now
            timed_out: list[object] = []
            timeout_blocked_by_running = self._has_active_detached_l5_futures()
            for future in list(pending):
                if not running_state.get(future, False):
                    continue
                started = started_at.get(future)
                if started is None or now - started <= timeout_sec:
                    continue
                canceled = bool(future.cancel())
                if not canceled:
                    self._register_detached_l5_future(future=future, job=futures[future])
                    timed_out.append(future)
                    timeout_blocked_by_running = True
                    continue
                job = futures[future]
                l5_buffers.failed_updates.append(
                    self._build_l5_failure_update(
                        job=job,
                        error_message=f"L5 future timeout after {int(timeout_sec)}s (cancelled)",
                    )
                )
                processed += 1
                timed_out.append(future)
            if timeout_blocked_by_running:
                for future in list(pending):
                    if running_state.get(future, False):
                        continue
                    queued_for = now - submitted_at.get(future, now)
                    if queued_for <= timeout_sec:
                        continue
                    canceled = bool(future.cancel())
                    if not canceled:
                        continue
                    job = futures[future]
                    l5_buffers.failed_updates.append(
                        self._build_l5_failure_update(
                            job=job,
                            error_message=f"L5 future timeout after {int(timeout_sec)}s (cancelled_queued)",
                        )
                    )
                    processed += 1
                    timed_out.append(future)
            for future in timed_out:
                pending.discard(future)
            for future in done:
                job = futures[future]
                try:
                    result = future.result()
                    self._l3_result_merger.merge(result=result, buffers=l5_buffers)
                    if self._result_counts_as_processed(result):
                        processed += 1
                except (CancelledError, RuntimeError, OSError, ValueError, TypeError, AttributeError) as exc:
                    l5_buffers.failed_updates.append(
                        self._build_l5_failure_update(
                            job=job,
                            error_message=f"L5 future failed: {exc}",
                        )
                    )
                    processed += 1
                should_flush_by_size = len(l5_buffers.done_ids) + len(l5_buffers.failed_updates) >= self._flush_batch_size
                should_flush_by_time = time.perf_counter() - last_flush_at >= self._flush_interval_sec
                if should_flush_by_size or should_flush_by_time:
                    self._l3_flush_coordinator.flush(buffers=l5_buffers, body_upserts=body_upserts)
                    last_flush_at = time.perf_counter()
        self._l3_flush_coordinator.flush(buffers=l5_buffers, body_upserts=body_upserts)
        return processed

    def _build_l5_failure_update(self, *, job: FileEnrichJobDTO, error_message: str) -> FileEnrichFailureUpdateDTO:
        """L5 worker 내부 예외/타임아웃을 queue failure update로 변환한다."""
        return FileEnrichFailureUpdateDTO(
            job_id=job.job_id,
            error_message=error_message,
            now_iso=now_iso8601_utc(),
            dead_threshold=int(self._policy.retry_max_attempts),
            backoff_base_sec=int(self._policy.retry_backoff_base_sec),
        )

    def _register_detached_l5_future(self, *, future: object, job: FileEnrichJobDTO) -> None:
        """취소되지 않은 running L5 future를 detached 추적으로 등록한다."""
        with self._l5_detached_lock:
            self._l5_detached_futures[future] = job

    def _collect_completed_detached_l5_futures(self, *, l5_buffers: _L3ResultBuffersDTO) -> int:
        """detached L5 future 중 완료된 항목을 수거해 버퍼에 반영한다."""
        with self._l5_detached_lock:
            items = list(self._l5_detached_futures.items())
        processed = 0
        for future, job in items:
            done_method = getattr(future, "done", None)
            if not callable(done_method) or not bool(done_method()):
                continue
            with self._l5_detached_lock:
                current_job = self._l5_detached_futures.get(future)
                if current_job is None:
                    continue
                self._l5_detached_futures.pop(future, None)
            try:
                result = future.result()
                self._l3_result_merger.merge(result=result, buffers=l5_buffers)
                if self._result_counts_as_processed(result):
                    processed += 1
            except (CancelledError, RuntimeError, OSError, ValueError, TypeError, AttributeError) as exc:
                l5_buffers.failed_updates.append(
                    self._build_l5_failure_update(
                        job=job,
                        error_message=f"L5 detached future failed: {exc}",
                    )
                )
                processed += 1
        return processed

    @staticmethod
    def _result_counts_as_processed(result: object) -> bool:
        """queue terminal progress(done/failed)가 있을 때만 처리 건수로 센다."""
        has_done_attr = hasattr(result, "done_id")
        has_failure_attr = hasattr(result, "failure_update")
        if not (has_done_attr or has_failure_attr):
            return True
        done_id = getattr(result, "done_id", None)
        failure_update = getattr(result, "failure_update", None)
        return done_id is not None or failure_update is not None

    def _has_active_detached_l5_futures(self) -> bool:
        """아직 완료되지 않은 detached L5 future 존재 여부를 반환한다."""
        with self._l5_detached_lock:
            for future in self._l5_detached_futures.keys():
                done_method = getattr(future, "done", None)
                if not callable(done_method):
                    return True
                if not bool(done_method()):
                    return True
        return False

    def _process_l3_group(
        self,
        *,
        group: list[FileEnrichJobDTO],
        buffers: _L3ResultBuffersDTO,
        body_upserts: list[CollectedFileBodyDTO],
    ) -> int:
        """L3 그룹 하나를 처리하고 처리 건수를 반환한다."""
        return self._l3_group_processor.process_group(
            group=group,
            buffers=buffers,
            body_upserts=body_upserts,
        )

    def process_enrich_jobs_bootstrap(self, limit: int) -> int:
        """bootstrap 모드 정책에 따라 L2/L3 비율을 조정한다."""
        self.refresh_indexing_mode()
        if self._indexing_mode == "steady":
            l2_budget = max(0, (int(limit) + 1) // 2)
            processed_l2 = self.process_enrich_jobs_l2(limit=l2_budget)
            l3_budget = max(0, int(limit) - int(processed_l2))
            processed_l3 = self.process_enrich_jobs_l3(limit=l3_budget)
            return processed_l2 + processed_l3
        _, l3_worker_count, _, _ = self._resolve_bootstrap_policy()
        processed_l2 = self.process_enrich_jobs_l2(limit=limit)
        if self._indexing_mode == "bootstrap_l2_priority":
            l3_limit = max(1, min(limit // 4, l3_worker_count * 32))
            if self._l3_ready_queue.qsize() <= l3_limit:
                return processed_l2
            processed_l3 = self.process_enrich_jobs_l3(limit=l3_limit)
            return processed_l2 + processed_l3
        processed_l3 = self.process_enrich_jobs_l3(limit=max(1, min(limit, l3_worker_count * 64)))
        return processed_l2 + processed_l3

    def compute_coverage_bps(self) -> tuple[int, int]:
        """L2/L3 커버리지를 bps 단위로 계산한다."""
        return self._get_or_init_l3_bootstrap_mode_service().compute_coverage_bps()

    def refresh_indexing_mode(self) -> None:
        """bootstrap 전환 정책을 갱신한다."""
        self._indexing_mode = self._get_or_init_l3_bootstrap_mode_service().refresh_indexing_mode(
            current_mode=self._indexing_mode,
            bootstrap_started_at=self._bootstrap_started_at,
            monotonic_now=time.monotonic(),
        )

    def _resolve_bootstrap_policy(self) -> tuple[bool, int, int, int]:
        return self._get_or_init_l3_bootstrap_mode_service().resolve_bootstrap_policy()

    def _resolve_lsp_language(self, relative_path: str) -> Language | None:
        return resolve_language_from_path(file_path=relative_path)

    def _rebalance_jobs_by_language(self, jobs: list[FileEnrichJobDTO]) -> list[FileEnrichJobDTO]:
        return self._get_or_init_l3_scheduling_service().rebalance_jobs_by_language(jobs)

    def _group_jobs_by_repo_and_language(self, jobs: list[FileEnrichJobDTO]) -> list[list[FileEnrichJobDTO]]:
        return self._get_or_init_l3_scheduling_service().group_jobs_by_repo_and_language(jobs)

    def _order_l3_groups_for_scheduling(self, groups: list[list[FileEnrichJobDTO]]) -> list[list[FileEnrichJobDTO]]:
        """PR3 baseline: backend가 제공하는 lane-aware 정렬 힌트로 L3 그룹 순서를 조정한다."""
        return self._get_or_init_l3_scheduling_service().order_l3_groups_for_scheduling(groups)

    def _resolve_l3_parallelism(self, jobs: list[FileEnrichJobDTO]) -> int:
        return self._get_or_init_l3_scheduling_service().resolve_l3_parallelism(jobs)

    def _get_or_init_l3_scheduling_service(self) -> L3SchedulingService:
        return self._get_or_init_runtime_service_registry().l3_scheduling_service()

    def _set_group_bulk_mode(self, group: list[FileEnrichJobDTO], enabled: bool) -> None:
        """LSP 백엔드에 그룹 단위 bulk 모드를 전달한다."""
        if len(group) == 0:
            return
        language = self._resolve_lsp_language(group[0].relative_path)
        if language is None:
            return
        setter = getattr(self._lsp_backend, "set_bulk_mode", None)
        if callable(setter):
            try:
                setter(group[0].repo_root, language, enabled)
            except (RuntimeError, OSError, ValueError, TypeError):
                return

    def _process_single_l3_job(self, job: FileEnrichJobDTO) -> _L3JobResultDTO:
        orchestrator = getattr(self, "_l3_orchestrator", None)
        if orchestrator is None:
            raise RuntimeError("L3Orchestrator is not initialized")
        result = orchestrator.process_job(job)
        if not isinstance(result, _L3JobResultDTO):
            raise TypeError(
                f"L3Orchestrator returned unexpected result type: {type(result)!r}"
            )
        return result

    def _process_single_l5_job(self, job: FileEnrichJobDTO) -> _L3JobResultDTO:
        orchestrator = getattr(self, "_l3_orchestrator", None)
        if orchestrator is None:
            raise RuntimeError("L3Orchestrator is not initialized")
        result = orchestrator.process_l5_job(job)
        if not isinstance(result, _L3JobResultDTO):
            raise TypeError(
                f"L3Orchestrator returned unexpected result type: {type(result)!r}"
            )
        return result


    def _run_l3_preprocess(self, *, job: FileEnrichJobDTO, file_row: object) -> L3PreprocessResultDTO | None:
        preprocess_io = getattr(self, "_l3_preprocess_io_service", None)
        if preprocess_io is None:
            preprocess_service = getattr(self, "_l3_preprocess_service", None)
            if preprocess_service is None:
                return None
            from sari.services.collection.l3.l3_preprocess_io_service import L3PreprocessIoService

            preprocess_io = L3PreprocessIoService(
                preprocess_service=preprocess_service,
                fallback_service=getattr(self, "_l3_degraded_fallback_service", None),
            )
            self._l3_preprocess_io_service = preprocess_io
        return preprocess_io.run(
            job=job,
            file_row=file_row,
            max_bytes=int(getattr(self, "_l3_preprocess_max_bytes", 262_144)),
        )

    def _schedule_l1_probe_after_l3_fallback(self, job: FileEnrichJobDTO) -> None:
        """L3 fail-open 시 백그라운드 L1 probe를 조건부로 예약한다."""
        self._get_or_init_l3_runtime_coordination_service().schedule_l1_probe_after_l3_fallback(job)

    def _try_escalate_scope_after_l3_extract_error(self, *, job: FileEnrichJobDTO, error_message: str) -> bool:
        """L3 extract 실패가 scope 문제라면 same-row scope escalation을 시도한다."""
        return self._get_or_init_l3_error_handling_service().try_escalate_scope_after_l3_extract_error(
            job=job,
            error_message=error_message,
        )

    def _try_defer_after_broker_lease_denial(self, *, job: FileEnrichJobDTO, error_message: str) -> bool:
        """broker lease 거부 오류는 실패가 아니라 queue defer로 되돌린다."""
        return self._get_or_init_l3_error_handling_service().try_defer_after_broker_lease_denial(
            job=job,
            error_message=error_message,
        )

    def _resolve_next_scope_root_for_escalation(self, *, job: FileEnrichJobDTO, next_scope_level: str) -> str:
        """PR-B baseline scope root fallback 계산 (실제 planner 연계는 PR1에서 강화)."""
        return self._get_or_init_l3_error_handling_service().resolve_next_scope_root_for_escalation(
            job=job,
            next_scope_level=next_scope_level,
        )

    def _get_or_init_l3_error_handling_service(self) -> L3ErrorHandlingService:
        return self._get_or_init_runtime_service_registry().l3_error_handling_service()

    def _get_or_init_l3_skip_runtime_service(self) -> L3SkipRuntimeService:
        return self._get_or_init_runtime_service_registry().l3_skip_runtime_service()

    def _get_or_init_l3_runtime_coordination_service(self) -> L3RuntimeCoordinationService:
        return self._get_or_init_runtime_service_registry().l3_runtime_coordination_service()

    def _record_scope_learning_after_l3_success(self, *, job: FileEnrichJobDTO) -> None:
        """성공한 scope 시도를 backend 학습 캐시에 기록한다 (Phase1 baseline)."""
        self._get_or_init_l3_runtime_coordination_service().record_scope_learning_after_l3_success(job=job)

    def _should_perf_trace_tick(self) -> bool:
        """테스트용 래퍼: 트레이스 샘플링 틱을 반환한다."""
        return self._perf_tracer.should_sample()

    def _perf_trace(self, event: str, **fields: object) -> None:
        """테스트용 래퍼: 성능 트레이스 로그를 남긴다."""
        self._perf_tracer.emit(event, **fields)

    def _parse_lsp_probe_l1_languages(self, items: tuple[str, ...]) -> set[Language]:
        """lsp_probe_l1_languages 설정을 Language 집합으로 변환한다."""
        return _parse_lsp_probe_l1_languages_shared(items)

    def _parse_l3_supported_languages(self, items: tuple[str, ...]) -> set[Language]:
        """l3_supported_languages 설정을 Language 집합으로 변환한다."""
        return _parse_l3_supported_languages_shared(items)

    def _evaluate_l5_admission_for_job(self, job: FileEnrichJobDTO, language: str) -> L4AdmissionDecisionDTO | None:
        runtime_service = getattr(self, "_l5_admission_runtime_service", None)
        if runtime_service is None:
            runtime_service = L5AdmissionRuntimeService(
                l4_admission_service=self._l4_admission_service,
                lsp_backend=getattr(self, "_lsp_backend", object()),
                monotonic_now=time.monotonic,
            )
            self._l5_admission_runtime_service = runtime_service
        state = L5AdmissionRuntimeState(
            total_decisions=int(getattr(self, "_l5_total_decisions", 0)),
            total_admitted=int(getattr(self, "_l5_total_admitted", 0)),
            batch_decisions=int(getattr(self, "_l5_batch_decisions", 0)),
            batch_admitted=int(getattr(self, "_l5_batch_admitted", 0)),
            calls_per_min_per_lang_max=int(getattr(self, "_l5_calls_per_min_per_lang_max", 1)),
            admitted_timestamps_by_lang=getattr(self, "_l5_admitted_timestamps_by_lang", {}),
            cooldown_until_by_scope_file=getattr(self, "_l5_cooldown_until_by_scope_file", {}),
            reject_counts_by_reason=self._get_or_init_l5_reject_counts(),
            cost_units_by_reason=self._get_or_init_l5_cost_units_by_reason(),
            cost_units_by_language=self._get_or_init_l5_cost_units_by_language(),
            cost_units_by_workspace=self._get_or_init_l5_cost_units_by_workspace(),
        )
        decision = runtime_service.evaluate_batch_for_job(
            state=state,
            job=job,
            language=language,
        )
        self._l5_total_decisions = state.total_decisions
        self._l5_total_admitted = state.total_admitted
        self._l5_batch_decisions = state.batch_decisions
        self._l5_batch_admitted = state.batch_admitted
        return decision

    def _get_or_init_l5_reject_counts(self) -> dict[L5RejectReason, int]:
        existing = getattr(self, "_l5_reject_counts_by_reason", None)
        if isinstance(existing, dict) and len(existing) > 0:
            return existing
        initialized: dict[L5RejectReason, int] = {reason: 0 for reason in L5RejectReason}
        setattr(self, "_l5_reject_counts_by_reason", initialized)
        return initialized

    def _get_or_init_l5_cost_units_by_reason(self) -> dict[str, float]:
        existing = getattr(self, "_l5_cost_units_by_reason", None)
        if isinstance(existing, dict):
            return existing
        initialized: dict[str, float] = {}
        setattr(self, "_l5_cost_units_by_reason", initialized)
        return initialized

    def _get_or_init_l5_cost_units_by_language(self) -> dict[str, float]:
        existing = getattr(self, "_l5_cost_units_by_language", None)
        if isinstance(existing, dict):
            return existing
        initialized: dict[str, float] = {}
        setattr(self, "_l5_cost_units_by_language", initialized)
        return initialized

    def _get_or_init_l5_cost_units_by_workspace(self) -> dict[str, float]:
        existing = getattr(self, "_l5_cost_units_by_workspace", None)
        if isinstance(existing, dict):
            return existing
        initialized: dict[str, float] = {}
        setattr(self, "_l5_cost_units_by_workspace", initialized)
        return initialized

    def _get_l5_lang_bucket(self, language_key: str) -> TokenBucket:
        existing = self._l5_lang_buckets.get(language_key)
        if existing is not None:
            return existing
        capacity = float(self._l5_tokens_per_10sec_per_lang_max)
        created = TokenBucket(
            capacity=capacity,
            refill_per_sec=capacity / 10.0,
            tokens=capacity,
            last_ts=time.monotonic(),
        )
        self._l5_lang_buckets[language_key] = created
        return created

    def _get_l5_workspace_bucket(self, workspace_uid: str) -> TokenBucket:
        existing = self._l5_workspace_buckets.get(workspace_uid)
        if existing is not None:
            return existing
        capacity = float(self._l5_tokens_per_10sec_per_workspace_max)
        created = TokenBucket(
            capacity=capacity,
            refill_per_sec=capacity / 10.0,
            tokens=capacity,
            last_ts=time.monotonic(),
        )
        self._l5_workspace_buckets[workspace_uid] = created
        return created

    def _get_or_init_l5_runtime_stats_service(self) -> L5RuntimeStatsService:
        return self._get_or_init_runtime_service_registry().l5_runtime_stats_service()

    def _get_or_init_l3_bootstrap_mode_service(self) -> L3BootstrapModeService:
        return self._get_or_init_runtime_service_registry().l3_bootstrap_mode_service()

    def _get_or_init_runtime_service_registry(self) -> EnrichRuntimeServiceRegistry:
        existing = getattr(self, "_runtime_services", None)
        if isinstance(existing, EnrichRuntimeServiceRegistry):
            return existing
        created = EnrichRuntimeServiceRegistry(self)
        self._runtime_services = created
        return created

    def _resolve_l3_skip_reason(self, job: FileEnrichJobDTO) -> str | None:
        """job이 L3 추출을 건너뛰어야 하는 사유를 반환한다."""
        return self._get_or_init_l3_skip_runtime_service().resolve_skip_reason(job)

    def _build_l3_skipped_readiness(
        self,
        *,
        job: FileEnrichJobDTO,
        reason: str,
        now_iso: str,
    ) -> ToolReadinessStateDTO:
        """L3 스킵 상태의 readiness 레코드를 생성한다."""
        return self._get_or_init_l3_skip_runtime_service().build_l3_skipped_readiness(
            job=job,
            reason=reason,
            now_iso=now_iso,
        )

    def _is_recent_tool_ready(self, job: FileEnrichJobDTO) -> bool:
        """최근 성공 상태면 L3 재추출을 건너뛸지 판단한다."""
        return self._get_or_init_l3_skip_runtime_service().is_recent_tool_ready(job)

    def _acquire_l3_jobs(self, limit: int) -> list[FileEnrichJobDTO]:
        return self._get_or_init_l3_runtime_coordination_service().acquire_l3_jobs(limit)

    def _is_deletion_hold_enabled(self) -> bool:
        return self._get_or_init_l3_runtime_coordination_service().is_deletion_hold_enabled()
