from __future__ import annotations
import fnmatch
import hashlib
import logging
import queue
import threading
import time
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Protocol
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
from solidlsp.ls_config import Language
from sari.core.exceptions import CollectionError, DaemonError, ErrorContext
from sari.core.language_registry import get_default_collection_extensions, resolve_language_from_path
from sari.core.text_decode import decode_bytes_with_policy
from sari.core.models import CandidateIndexChangeDTO, CollectionPolicyDTO, CollectionScanResultDTO, CollectedFileL1DTO, FileReadResultDTO, PipelineMetricsDTO, now_iso8601_utc
from sari.db.repositories.file_body_repository import FileBodyDecodeError, FileBodyRepository
from sari.db.repositories.file_collection_repository import FileCollectionRepository
from sari.db.repositories.file_enrich_queue_repository import FileEnrichQueueRepository
from sari.db.repositories.lsp_tool_data_repository import LspToolDataRepository
from sari.db.repositories.pipeline_job_event_repository import PipelineJobEventRepository
from sari.db.repositories.pipeline_error_event_repository import PipelineErrorEventRepository
from sari.db.repositories.pipeline_policy_repository import PipelinePolicyRepository
from sari.db.repositories.tool_readiness_repository import ToolReadinessRepository
from sari.db.repositories.workspace_repository import WorkspaceRepository
from sari.lsp.hub import LspHub
from sari.lsp.path_normalizer import normalize_location_to_repo_relative, normalize_repo_relative_path
from sari.services.collection import CollectionErrorPolicy, CollectionRuntimePort, EnrichEngine, EventWatcher, FileScanner, PipelineMetricsService, PipelineWorker, RuntimeManager
from solidlsp.ls_exceptions import SolidLSPException
log = logging.getLogger(__name__)

@dataclass(frozen=True)
class LspExtractionResultDTO:
    symbols: list[dict[str, object]]
    relations: list[dict[str, object]]
    error_message: str | None

class LspExtractionBackend(Protocol):

    def extract(self, repo_root: str, relative_path: str, content_hash: str) -> LspExtractionResultDTO:
        ...

class CandidateIndexSink(Protocol):

    def mark_repo_dirty(self, repo_root: str) -> None:
        ...

    def mark_file_dirty(self, repo_root: str, relative_path: str) -> None:
        ...

    def record_upsert(self, change: CandidateIndexChangeDTO) -> None:
        ...

    def record_delete(self, repo_root: str, relative_path: str, reason: str) -> None:
        ...

class VectorIndexSink(Protocol):

    def upsert_file_embedding(self, repo_root: str, relative_path: str, content_hash: str, content_text: str) -> None:
        ...

class SolidLspExtractionBackend:

    def __init__(self, hub: LspHub) -> None:
        self._hub = hub
        self._prewarmed_keys: set[tuple[Language, str]] = set()
        self._hot_languages_by_repo: dict[str, set[Language]] = {}
        self._prewarm_lock = threading.Lock()

    def extract(self, repo_root: str, relative_path: str, content_hash: str) -> LspExtractionResultDTO:
        del content_hash
        normalized_relative_path = normalize_repo_relative_path(relative_path)
        try:
            language = self._hub.resolve_language(normalized_relative_path)
            self._ensure_prewarm(language=language, repo_root=repo_root)
            lsp = self._hub.get_or_start(language=language, repo_root=repo_root)
            document_symbols = lsp.request_document_symbols(normalized_relative_path).iter_symbols()
            raw_symbols = list(document_symbols)
        except SolidLSPException as exc:
            message = str(exc)
            if 'ERR_LSP_SYNC_OPEN_FAILED' in message:
                return LspExtractionResultDTO(symbols=[], relations=[], error_message=f'ERR_LSP_SYNC_OPEN_FAILED: repo={repo_root}, path={normalized_relative_path}, reason={message}')
            if 'ERR_LSP_SYNC_CHANGE_FAILED' in message:
                return LspExtractionResultDTO(symbols=[], relations=[], error_message=f'ERR_LSP_SYNC_CHANGE_FAILED: repo={repo_root}, path={normalized_relative_path}, reason={message}')
            return LspExtractionResultDTO(symbols=[], relations=[], error_message=f'ERR_LSP_DOCUMENT_SYMBOL_FAILED: repo={repo_root}, path={normalized_relative_path}, reason={message}')
        except (DaemonError, RuntimeError, OSError, ValueError) as exc:
            return LspExtractionResultDTO(symbols=[], relations=[], error_message=f'LSP 추출 실패: {exc}')
        symbols: list[dict[str, object]] = []
        for raw in raw_symbols:
            if not isinstance(raw, dict):
                continue
            location = raw.get('location')
            resolved_relative_path = normalized_relative_path
            if isinstance(location, dict):
                resolved_relative_path = normalize_location_to_repo_relative(
                    location=location,
                    fallback_relative_path=normalized_relative_path,
                    repo_root=repo_root,
                )
            location = raw.get('location')
            if not isinstance(location, dict):
                location = {}
            range_data = location.get('range')
            line = 0
            end_line = 0
            if isinstance(range_data, dict):
                start_data = range_data.get('start')
                end_data = range_data.get('end')
                if isinstance(start_data, dict):
                    line = int(start_data.get('line', 0))
                if isinstance(end_data, dict):
                    end_line = int(end_data.get('line', line))
            symbol_name = str(raw.get('name', ''))
            symbol_kind = str(raw.get('kind', ''))
            parent_symbol = raw.get('parent')
            parent_symbol_key = self._build_symbol_key(
                repo_root=repo_root,
                relative_path=resolved_relative_path,
                symbol=parent_symbol,
                fallback_parent_key=None,
            )
            symbol_key = self._build_symbol_key(
                repo_root=repo_root,
                relative_path=resolved_relative_path,
                symbol=raw,
                fallback_parent_key=parent_symbol_key,
            )
            symbols.append(
                {
                    'name': symbol_name,
                    'kind': symbol_kind,
                    'line': line,
                    'end_line': end_line,
                    'symbol_key': symbol_key,
                    'parent_symbol_key': parent_symbol_key,
                    'depth': self._resolve_symbol_depth(raw),
                    'container_name': self._resolve_container_name(raw),
                }
            )
        return LspExtractionResultDTO(symbols=symbols, relations=[], error_message=None)

    def _resolve_symbol_depth(self, symbol: dict[str, object]) -> int:
        """심볼 parent 체인을 따라 depth를 계산한다."""
        depth = 0
        current = symbol.get('parent')
        while isinstance(current, dict):
            depth += 1
            current = current.get('parent')
        return depth

    def _resolve_container_name(self, symbol: dict[str, object]) -> str | None:
        """부모 심볼 이름을 container_name으로 반환한다."""
        parent = symbol.get('parent')
        if not isinstance(parent, dict):
            return None
        parent_name = parent.get('name')
        if isinstance(parent_name, str) and parent_name.strip() != '':
            return parent_name
        return None

    def _build_symbol_key(
        self,
        repo_root: str,
        relative_path: str,
        symbol: object,
        fallback_parent_key: str | None,
    ) -> str | None:
        """결정적 심볼 키를 생성한다."""
        if not isinstance(symbol, dict):
            return None
        name = symbol.get('name')
        kind = symbol.get('kind')
        if not isinstance(name, str) or not isinstance(kind, str):
            return None
        line = 0
        end_line = 0
        location = symbol.get('location')
        if isinstance(location, dict):
            range_data = location.get('range')
            if isinstance(range_data, dict):
                start_data = range_data.get('start')
                end_data = range_data.get('end')
                if isinstance(start_data, dict):
                    line = int(start_data.get('line', 0))
                if isinstance(end_data, dict):
                    end_line = int(end_data.get('line', line))
        parent_key = fallback_parent_key or 'root'
        key_text = f'{repo_root}:{relative_path}:{name}:{kind}:{line}:{end_line}:{parent_key}'
        return hashlib.sha1(key_text.encode('utf-8')).hexdigest()

    def _ensure_prewarm(self, language: Language, repo_root: str) -> None:
        key = (language, str(Path(repo_root).resolve()))
        with self._prewarm_lock:
            if key in self._prewarmed_keys:
                return
            allowed_languages = self._hot_languages_by_repo.get(key[1])
            if allowed_languages is not None and language not in allowed_languages:
                self._prewarmed_keys.add(key)
                return
            self._hub.prewarm_language_pool(language=language, repo_root=repo_root)
            self._prewarmed_keys.add(key)

    def configure_hot_languages(self, repo_root: str, languages: set[Language]) -> None:
        normalized = str(Path(repo_root).resolve())
        with self._prewarm_lock:
            self._hot_languages_by_repo[normalized] = set(languages)

class FileCollectionService:
    PRIORITY_HIGH = 90
    PRIORITY_MEDIUM = 60
    PRIORITY_LOW = 30
    ENRICH_FLUSH_BATCH_SIZE = 128
    ENRICH_FLUSH_INTERVAL_SEC = 0.5
    ENRICH_FLUSH_MAX_BODY_BYTES = 16 * 1024 * 1024
    SCAN_FLUSH_BATCH_SIZE = 500
    SCAN_FLUSH_INTERVAL_SEC = 0.5
    SCAN_HASH_MAX_WORKERS = 8
    LSP_PREWARM_TOP_LANGUAGE_COUNT = 2
    LSP_PREWARM_MIN_LANGUAGE_FILES = 32

    def __init__(self, workspace_repo: WorkspaceRepository, file_repo: FileCollectionRepository, enrich_queue_repo: FileEnrichQueueRepository, body_repo: FileBodyRepository, lsp_repo: LspToolDataRepository, readiness_repo: ToolReadinessRepository, policy: CollectionPolicyDTO, lsp_backend: LspExtractionBackend, policy_repo: PipelinePolicyRepository | None=None, event_repo: PipelineJobEventRepository | None=None, error_event_repo: PipelineErrorEventRepository | None=None, candidate_index_sink: CandidateIndexSink | None=None, vector_index_sink: VectorIndexSink | None=None, run_mode: str='dev', parent_alive_probe: Callable[[], bool] | None=None, persist_body_for_read: bool=True) -> None:
        self._workspace_repo = workspace_repo
        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._error_event_repo = error_event_repo
        self._candidate_index_sink = candidate_index_sink
        self._vector_index_sink = vector_index_sink
        self._run_mode = 'prod' if run_mode == 'prod' else 'dev'
        self._parent_alive_probe = parent_alive_probe
        self._persist_body_for_read = persist_body_for_read
        self._stop_event = threading.Event()
        self._scheduler_thread: threading.Thread | None = None
        self._enrich_threads: list[threading.Thread] = []
        self._watcher_thread: threading.Thread | None = None
        self._event_queue: queue.Queue[tuple[str, str, str]] = queue.Queue()
        self._l3_ready_queue: queue.Queue[FileEnrichJobDTO] = queue.Queue()
        self._watcher_debounce_ms = 300
        self._debounce_events: dict[tuple[str, str], tuple[float, str, str]] = {}
        self._debounce_lock = threading.Lock()
        self._enrich_latency_samples_ms: list[float] = []
        self._throughput_samples_jobs_per_sec: list[float] = []
        self._throughput_ema_jobs_per_sec = 0.0
        self._throughput_alpha = 0.2
        self._metrics_lock = threading.Lock()
        self._worker_state = 'running'
        self._last_error_code: str | None = None
        self._last_error_message: str | None = None
        self._last_error_at: str | None = None
        self._indexing_mode = 'steady'
        self._scanner = FileScanner(
            file_repo=self._file_repo,
            enrich_queue_repo=self._enrich_queue_repo,
            candidate_index_sink=self._candidate_index_sink,
            resolve_lsp_language=self._resolve_lsp_language,
            configure_lsp_prewarm_languages=self._configure_lsp_prewarm_languages,
            load_gitignore_spec=self._load_gitignore_spec,
            is_collectible=self._is_collectible,
            priority_low=self.PRIORITY_LOW,
            priority_medium=self.PRIORITY_MEDIUM,
            scan_flush_batch_size=self.SCAN_FLUSH_BATCH_SIZE,
            scan_flush_interval_sec=self.SCAN_FLUSH_INTERVAL_SEC,
            scan_hash_max_workers=self.SCAN_HASH_MAX_WORKERS,
        )
        self._error_policy = CollectionErrorPolicy(
            error_event_repo=self._error_event_repo,
            run_mode=self._run_mode,
            stop_background=self._stop_event.set,
        )
        self._enrich_engine = EnrichEngine(
            file_repo=self._file_repo,
            enrich_queue_repo=self._enrich_queue_repo,
            body_repo=self._body_repo,
            lsp_repo=self._lsp_repo,
            readiness_repo=self._readiness_repo,
            policy=self._policy,
            lsp_backend=self._lsp_backend,
            policy_repo=self._policy_repo,
            event_repo=self._event_repo,
            vector_index_sink=self._vector_index_sink,
            run_mode=self._run_mode,
            persist_body_for_read=self._persist_body_for_read,
            l3_ready_queue=self._l3_ready_queue,
            error_policy=self._error_policy,
            record_enrich_latency=self._record_enrich_latency,
            assert_parent_alive=self._assert_parent_alive,
            flush_batch_size=self.ENRICH_FLUSH_BATCH_SIZE,
            flush_interval_sec=self.ENRICH_FLUSH_INTERVAL_SEC,
            flush_max_body_bytes=self.ENRICH_FLUSH_MAX_BODY_BYTES,
        )
        self._pipeline_worker = PipelineWorker(
            process_enrich_jobs=self._enrich_engine.process_enrich_jobs,
            process_enrich_jobs_l2=self._enrich_engine.process_enrich_jobs_l2,
            process_enrich_jobs_l3=self._enrich_engine.process_enrich_jobs_l3,
        )
        self._runtime_manager = RuntimeManager(
            stop_event=self._stop_event,
            enrich_queue_repo=self._enrich_queue_repo,
            workspace_repo=self._workspace_repo,
            policy=self._policy,
            policy_repo=self._policy_repo,
            assert_parent_alive=self._assert_parent_alive,
            scan_once=self.scan_once,
            process_enrich_jobs_bootstrap=self._enrich_engine.process_enrich_jobs_bootstrap,
            handle_background_collection_error=self._handle_background_collection_error_proxy,
            prune_error_events_if_needed=self._error_policy.prune_error_events_if_needed,
            watcher_loop=self._watcher_loop,
        )
        self._watcher = EventWatcher(
            workspace_repo=self._workspace_repo,
            file_repo=self._file_repo,
            candidate_index_sink=self._candidate_index_sink,
            event_queue=self._event_queue,
            stop_event=self._stop_event,
            debounce_events=self._debounce_events,
            debounce_lock=self._debounce_lock,
            watcher_debounce_ms=lambda: self._watcher_debounce_ms,
            assert_parent_alive=self._assert_parent_alive,
            index_file_with_priority=self._index_file_with_priority,
            handle_background_collection_error=self._handle_background_collection_error_proxy,
            priority_high=self.PRIORITY_HIGH,
            set_observer=self._runtime_manager.set_observer,
        )
        self._metrics_service = PipelineMetricsService(
            refresh_indexing_mode=self._enrich_engine.refresh_indexing_mode,
            enrich_queue_repo=self._enrich_queue_repo,
            file_repo=self._file_repo,
            l3_queue_size=lambda: self._l3_ready_queue.qsize(),
            metrics_lock=self._metrics_lock,
            enrich_latency_samples_ms=self._enrich_latency_samples_ms,
            throughput_samples_jobs_per_sec=self._throughput_samples_jobs_per_sec,
            get_throughput_ema=lambda: self._throughput_ema_jobs_per_sec,
            set_throughput_ema=self._set_throughput_ema_jobs_per_sec,
            throughput_alpha=self._throughput_alpha,
            enrich_threads_count=self._runtime_manager.enrich_thread_count,
            compute_coverage_bps=self._enrich_engine.compute_coverage_bps,
            indexing_mode=self._enrich_engine.indexing_mode,
            worker_state=lambda: self._worker_state,
            last_error_code=self._error_policy.last_error_code,
            last_error_message=self._error_policy.last_error_message,
            last_error_at=self._error_policy.last_error_at,
        )

    def scan_once(self, repo_root: str) -> CollectionScanResultDTO:
        """L1 스캔을 전용 스캐너 컴포넌트로 위임한다."""
        return self._scanner.scan_once(repo_root)

    def index_file(self, repo_root: str, relative_path: str) -> CollectionScanResultDTO:
        """단일 파일 인덱싱을 전용 스캐너 컴포넌트로 위임한다."""
        return self._scanner.index_file(repo_root, relative_path)

    def process_enrich_jobs(self, limit: int) -> int:
        """L2/L3 통합 보강 처리를 전용 워커 컴포넌트로 위임한다."""
        return self._pipeline_worker.process_enrich_jobs(limit)

    def process_enrich_jobs_l2(self, limit: int) -> int:
        """L2 보강 처리를 전용 워커 컴포넌트로 위임한다."""
        return self._pipeline_worker.process_enrich_jobs_l2(limit)

    def process_enrich_jobs_l3(self, limit: int) -> int:
        """L3 보강 처리를 전용 워커 컴포넌트로 위임한다."""
        return self._pipeline_worker.process_enrich_jobs_l3(limit)

    def _watcher_loop(self) -> None:
        """watcher 루프를 전용 이벤트 컴포넌트로 위임한다."""
        self._watcher.watcher_loop()

    def _handle_fs_event(self, event_type: str, src_path: str, dest_path: str) -> None:
        """파일 시스템 이벤트 처리를 전용 이벤트 컴포넌트로 위임한다."""
        self._watcher.handle_fs_event(event_type=event_type, src_path=src_path, dest_path=dest_path)

    def _push_debounced_event(self, event_type: str, src_path: str, dest_path: str) -> None:
        """디바운스 이벤트 적재를 전용 이벤트 컴포넌트로 위임한다."""
        self._watcher.push_debounced_event(event_type=event_type, src_path=src_path, dest_path=dest_path)

    def _flush_debounced_events(self) -> None:
        """디바운스 이벤트 flush를 전용 이벤트 컴포넌트로 위임한다."""
        self._watcher.flush_debounced_events()

    def get_pipeline_metrics(self) -> PipelineMetricsDTO:
        """파이프라인 메트릭 계산을 전용 메트릭 컴포넌트로 위임한다."""
        return self._metrics_service.get_pipeline_metrics()

    def _record_enrich_latency(self, latency_ms: float) -> None:
        """처리 지연시간 기록을 전용 메트릭 컴포넌트로 위임한다."""
        self._metrics_service.record_enrich_latency(latency_ms)

    def _set_throughput_ema_jobs_per_sec(self, value: float) -> None:
        """처리량 EMA 값을 명시적으로 갱신한다."""
        self._throughput_ema_jobs_per_sec = value

    def list_files(self, repo_root: str, limit: int, prefix: str | None) -> list[dict[str, object]]:
        if limit <= 0:
            raise CollectionError(ErrorContext(code='ERR_INVALID_LIMIT', message='limit는 1 이상이어야 합니다'))
        rows = self._file_repo.list_files(repo_root=repo_root, limit=limit, prefix=prefix)
        return [{'repo': item.repo, 'relative_path': item.relative_path, 'size_bytes': item.size_bytes, 'mtime_ns': item.mtime_ns, 'content_hash': item.content_hash, 'enrich_state': item.enrich_state} for item in rows]

    def read_file(self, repo_root: str, relative_path: str, offset: int, limit: int | None) -> FileReadResultDTO:
        if offset < 0:
            raise CollectionError(ErrorContext(code='ERR_INVALID_OFFSET', message='offset은 0 이상이어야 합니다'))
        if limit is not None and limit <= 0:
            raise CollectionError(ErrorContext(code='ERR_INVALID_LIMIT', message='limit는 1 이상이어야 합니다'))
        row = self._file_repo.get_file(repo_root=repo_root, relative_path=relative_path)
        if row is None or row.is_deleted:
            raise CollectionError(ErrorContext(code='ERR_FILE_NOT_FOUND', message='파일 메타데이터를 찾을 수 없습니다'))
        try:
            body_text = self._body_repo.read_body_text(repo_root=repo_root, relative_path=relative_path, content_hash=row.content_hash)
        except FileBodyDecodeError as exc:
            self._error_policy.record_error_event(component='file_collection_service', phase='read_file', severity='error', error_code='ERR_L2_BODY_CORRUPT', error_message=str(exc), error_type=type(exc).__name__, repo_root=repo_root, relative_path=relative_path, job_id=None, attempt_count=0, context_data={'content_hash': row.content_hash}, worker_name='http_read', stacktrace_text=traceback.format_exc())
            raise CollectionError(ErrorContext(code='ERR_L2_BODY_CORRUPT', message='L2 본문 데이터가 손상되어 읽을 수 없습니다')) from exc
        source = 'l2'
        if body_text is None:
            source = 'fs'
            file_path = Path(row.absolute_path)
            if not file_path.exists() or not file_path.is_file():
                raise CollectionError(ErrorContext(code='ERR_FILE_NOT_FOUND', message='파일 시스템에서 파일을 찾을 수 없습니다'))
            decoded = decode_bytes_with_policy(file_path.read_bytes())
            body_text = decoded.text
        lines = body_text.splitlines()
        total_lines = len(lines)
        end_index = total_lines if limit is None else min(total_lines, offset + limit)
        sliced = lines[offset:end_index]
        next_offset = end_index if end_index < total_lines else None
        return FileReadResultDTO(relative_path=relative_path, content='\n'.join(sliced), start_line=offset + 1, end_line=end_index, source=source, total_lines=total_lines, is_truncated=next_offset is not None, next_offset=next_offset)

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

    def _configure_lsp_prewarm_languages(self, repo_root: str, language_counts: dict[Language, int]) -> None:
        configure_func = getattr(self._lsp_backend, 'configure_hot_languages', None)
        if not callable(configure_func):
            return
        candidates = [(language, count) for language, count in language_counts.items() if count >= self.LSP_PREWARM_MIN_LANGUAGE_FILES]
        candidates.sort(key=lambda item: item[1], reverse=True)
        selected = {language for language, _ in candidates[:self.LSP_PREWARM_TOP_LANGUAGE_COUNT]}
        configure_func(repo_root=repo_root, languages=selected)

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

    def start_background(self) -> None:
        self._enrich_engine.reset_runtime_state()
        self._worker_state = 'running'
        self._runtime_manager.start_background()

    def stop_background(self) -> None:
        self._runtime_manager.stop_background()

    def list_error_events(self, limit: int, offset: int=0, repo_root: str | None=None, error_code: str | None=None) -> list[dict[str, object]]:
        if self._error_event_repo is None:
            return []
        items = self._error_event_repo.list_events(limit=limit, offset=offset, repo_root=repo_root, error_code=error_code)
        return [item.to_dict() for item in items]

    def get_error_event(self, event_id: str) -> dict[str, object] | None:
        if self._error_event_repo is None:
            return None
        item = self._error_event_repo.get_event(event_id=event_id)
        if item is None:
            return None
        return item.to_dict()

    def _is_deletion_hold_enabled(self) -> bool:
        if self._policy_repo is None:
            return False
        return bool(self._policy_repo.get_policy().deletion_hold)

    def _record_error_event(self, component: str, phase: str, severity: str, error_code: str, error_message: str, error_type: str, repo_root: str | None, relative_path: str | None, job_id: str | None, attempt_count: int, context_data: dict[str, object], worker_name: str='collection', stacktrace_text: str | None=None) -> None:
        self._error_policy.record_error_event(component=component, phase=phase, severity=severity, error_code=error_code, error_message=error_message, error_type=error_type, repo_root=repo_root, relative_path=relative_path, job_id=job_id, attempt_count=attempt_count, context_data=context_data, worker_name=worker_name, stacktrace_text=stacktrace_text)

    def _index_file_with_priority(self, repo_root: str, relative_path: str, priority: int, enqueue_source: str) -> None:
        if relative_path.strip() == '':
            raise CollectionError(ErrorContext(code='ERR_RELATIVE_PATH_REQUIRED', message='relative_path는 필수입니다'))
        root = Path(repo_root).expanduser().resolve()
        file_path = (root / relative_path).resolve()
        if not file_path.exists() or not file_path.is_file():
            raise CollectionError(ErrorContext(code='ERR_FILE_NOT_FOUND', message='대상 파일을 찾을 수 없습니다'))
        now_iso = now_iso8601_utc()
        content_bytes = file_path.read_bytes()
        content_hash = hashlib.sha256(content_bytes).hexdigest()
        l1_row = CollectedFileL1DTO(repo_root=str(root), relative_path=str(file_path.relative_to(root).as_posix()), absolute_path=str(file_path), repo_label=root.name, mtime_ns=file_path.stat().st_mtime_ns, size_bytes=file_path.stat().st_size, content_hash=content_hash, is_deleted=False, last_seen_at=now_iso, updated_at=now_iso, enrich_state='PENDING')
        self._file_repo.upsert_file(l1_row)
        self._enrich_queue_repo.enqueue(repo_root=str(root), relative_path=str(file_path.relative_to(root).as_posix()), content_hash=content_hash, priority=priority, enqueue_source=enqueue_source, now_iso=now_iso)
        if self._candidate_index_sink is not None:
            self._candidate_index_sink.record_upsert(CandidateIndexChangeDTO(repo_root=str(root), relative_path=str(file_path.relative_to(root).as_posix()), absolute_path=str(file_path), content_hash=content_hash, mtime_ns=file_path.stat().st_mtime_ns, size_bytes=file_path.stat().st_size, event_source=enqueue_source, recorded_at=now_iso))

    def _assert_parent_alive(self, worker_name: str) -> None:
        if self._parent_alive_probe is None:
            return
        if self._parent_alive_probe():
            return
        raise CollectionError(ErrorContext(code='ERR_ORPHAN_DETECTED', message=f'고아 워커 감지: {worker_name}'))

    def _handle_background_collection_error_proxy(self, exc: CollectionError, phase: str, worker_name: str) -> bool:
        """오류 정책 컴포넌트로 background 오류 처리를 위임한다."""
        should_stop = self._error_policy.handle_background_collection_error(exc=exc, phase=phase, worker_name=worker_name)
        if should_stop:
            self._worker_state = 'failed'
        return should_stop

    def _prune_error_events_if_needed(self) -> None:
        self._error_policy.prune_error_events_if_needed()

    def _load_gitignore_spec(self, repo_root: Path) -> PathSpec:
        gitignore_path = repo_root / '.gitignore'
        patterns: list[str] = []
        if gitignore_path.exists():
            patterns = gitignore_path.read_text(encoding='utf-8').splitlines()
        return PathSpec.from_lines(GitWildMatchPattern, patterns)

    def _is_collectible(self, file_path: Path, repo_root: Path, gitignore_spec: PathSpec) -> bool:
        if file_path.stat().st_size > self._policy.max_file_size_bytes:
            return False
        relative_posix = str(file_path.relative_to(repo_root).as_posix())
        if gitignore_spec.match_file(relative_posix):
            return False
        if any((part.startswith('.') for part in file_path.parts)):
            return False
        suffix = file_path.suffix.lower()
        if suffix not in self._policy.include_ext:
            return False
        for pattern in self._policy.exclude_globs:
            if fnmatch.fnmatch(relative_posix, pattern):
                return False
        return True

def build_default_file_collection_service(workspace_repo: WorkspaceRepository, file_repo: FileCollectionRepository, enrich_queue_repo: FileEnrichQueueRepository, body_repo: FileBodyRepository, lsp_repo: LspToolDataRepository, readiness_repo: ToolReadinessRepository, policy_repo: PipelinePolicyRepository | None=None, event_repo: PipelineJobEventRepository | None=None, error_event_repo: PipelineErrorEventRepository | None=None, candidate_index_sink: CandidateIndexSink | None=None, vector_index_sink: VectorIndexSink | None=None, retry_max_attempts: int=5, retry_backoff_base_sec: int=1, queue_poll_interval_ms: int=500, include_ext: tuple[str, ...] | None=None, exclude_globs: tuple[str, ...] | None=None, watcher_debounce_ms: int=300, run_mode: str='dev', parent_alive_probe: Callable[[], bool] | None=None, lsp_backend: LspExtractionBackend | None=None, persist_body_for_read: bool=True) -> CollectionRuntimePort:
    resolved_include_ext = include_ext if include_ext is not None else get_default_collection_extensions()
    resolved_exclude_globs = exclude_globs if exclude_globs is not None else ('**/.git/**', '**/node_modules/**', '**/dist/**', '**/build/**')
    policy = CollectionPolicyDTO(include_ext=resolved_include_ext, exclude_globs=resolved_exclude_globs, max_file_size_bytes=512 * 1024, scan_interval_sec=180, max_enrich_batch=20, retry_max_attempts=retry_max_attempts, retry_backoff_base_sec=retry_backoff_base_sec, queue_poll_interval_ms=queue_poll_interval_ms)
    resolved_lsp_backend = lsp_backend if lsp_backend is not None else SolidLspExtractionBackend(LspHub())
    service = FileCollectionService(workspace_repo=workspace_repo, file_repo=file_repo, enrich_queue_repo=enrich_queue_repo, body_repo=body_repo, lsp_repo=lsp_repo, readiness_repo=readiness_repo, policy=policy, lsp_backend=resolved_lsp_backend, policy_repo=policy_repo, event_repo=event_repo, error_event_repo=error_event_repo, candidate_index_sink=candidate_index_sink, vector_index_sink=vector_index_sink, run_mode=run_mode, parent_alive_probe=parent_alive_probe, persist_body_for_read=persist_body_for_read)
    service._watcher_debounce_ms = max(50, watcher_debounce_ms)
    return service
