"""Filesystem indexer for codectx."""

from __future__ import annotations

import hashlib
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Callable

from codectx.db.repository import CodeRepository
from codectx.parser.types import DependencyRecord
from codectx.parser.registry import SUPPORTED_EXTENSIONS, parse_file

try:
    import pathspec as _pathspec  # type: ignore
except ImportError:  # pragma: no cover
    _pathspec = None  # type: ignore

SKIP_DIRS = {".git", ".vscode", "node_modules", "vendor", "__pycache__", "target", "build", "dist", ".venv", "venv", "env"}


@dataclass(slots=True)
class IndexingSummary:
    indexed_files: int = 0
    skipped_files: int = 0
    unchanged_files: int = 0


class CodeIndexer:
    def __init__(
        self,
        repository: CodeRepository,
        max_file_size_bytes: int = 10 * 1024 * 1024,
        max_workers: int = 4,
        version_id: int | None = None,
    ) -> None:
        self.repository = repository
        self.max_file_size_bytes = max_file_size_bytes
        self.max_workers = max_workers
        self.version_id = version_id
        self.repository.create_schema()

    def list_files(self, directory: str | Path) -> list[Path]:
        root = Path(directory).resolve()
        return list(self._iter_files(root))

    def index_directory(
        self,
        directory: str | Path,
        progress_callback: Callable[[], None] | None = None,
        files: list[Path] | None = None,
        log_callback: Callable[[str], None] | None = None,
        write_total_callback: Callable[[int], None] | None = None,
        write_progress_callback: Callable[[int], None] | None = None,
        db_batch_size: int = 50,
    ) -> IndexingSummary:
        summary = IndexingSummary()
        root = Path(directory).resolve()
        parsed_records: list[tuple[int, Path, object, list]] = []

        if files is None:
            files = self.list_files(directory)

        total = len(files)
        if log_callback:
            log_callback(f"🔍 Parsing {total} files with {self.max_workers} workers...")

        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            future_to_path = {executor.submit(self._parse_file, fp): fp for fp in files}
            processed = 0
            for future in as_completed(future_to_path):
                processed += 1
                if progress_callback:
                    progress_callback()
                parsed = future.result()
                if parsed is None:
                    summary.skipped_files += 1
                    continue
                file_id, parse_result = parsed
                # Track if file was actually updated or just unchanged
                if parse_result is not None:
                    file_path = future_to_path[future].resolve()
                    resolved_deps = [
                        self._resolve_dependency(file_path, root, parse_result.language, dep)
                        for dep in parse_result.dependencies
                    ]
                    parsed_records.append((file_id, file_path, parse_result, resolved_deps))
                    summary.indexed_files += 1
                else:
                    # File unchanged, skip re-inserting
                    summary.unchanged_files += 1

        if log_callback:
            entity_count = sum(len(pr.entities) for _, _, pr, _ in parsed_records)
            dep_count = sum(len(rd) for _, _, _, rd in parsed_records)
            log_callback(
                f"✅ Parsed {summary.indexed_files} changed files "
                f"({summary.unchanged_files} unchanged, {summary.skipped_files} skipped)"
            )
            log_callback(f"🧱 Extracted {entity_count} entities, {dep_count} dependencies")

        # Bulk-write in batches to avoid long-running transactions
        bulk_records = [(file_id, parse_result, resolved_deps)
                        for file_id, _, parse_result, resolved_deps in parsed_records]

        if bulk_records:
            total_batches = (len(bulk_records) + db_batch_size - 1) // db_batch_size
            if log_callback:
                log_callback(f"💾 Writing {len(bulk_records)} files to database in {total_batches} batches...")
            if write_total_callback:
                write_total_callback(len(bulk_records))
            for batch_idx in range(0, len(bulk_records), db_batch_size):
                batch = bulk_records[batch_idx:batch_idx + db_batch_size]
                self.repository.bulk_write_file_data(batch)
                if write_progress_callback:
                    write_progress_callback(len(batch))

            if log_callback:
                log_callback(f"✨ Database write complete ({len(bulk_records)} files)")

        return summary

    def _iter_files(self, root: Path) -> Iterator[Path]:
        gitignore_spec = self._load_gitignore(root)
        for path in root.rglob("*"):
            if path.is_dir():
                continue
            if any(part in SKIP_DIRS for part in path.parts):
                continue
            if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
                continue
            if gitignore_spec is not None:
                rel = path.relative_to(root).as_posix()
                if gitignore_spec.match_file(rel):
                    continue
            yield path

    @staticmethod
    def _load_gitignore(root: Path):
        """Return a pathspec.PathSpec for the root .gitignore, or None."""
        if _pathspec is None:
            return None
        gitignore = root / ".gitignore"
        if not gitignore.exists():
            return None
        lines = gitignore.read_text(encoding="utf-8", errors="ignore").splitlines()
        return _pathspec.PathSpec.from_lines("gitwildmatch", lines)

    def _parse_file(self, file_path: Path) -> tuple[int, object | None] | None:
        if self.max_file_size_bytes and file_path.stat().st_size > self.max_file_size_bytes:
            return None

        content = file_path.read_text(encoding="utf-8", errors="ignore")
        file_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()

        # Check if file needs updating by comparing hashes
        if not self.repository.file_needs_update(str(file_path), file_hash):
            existing_file = self.repository.get_file_by_path(str(file_path))
            if existing_file:
                # Verify entities were actually written (guards against interrupted indexing)
                entity_counts = self.repository.get_file_entity_counts([existing_file.id])
                if entity_counts.get(existing_file.id, 0) > 0:
                    return existing_file.id, None  # Truly unchanged and complete
                # Hash matches but no entities — previous run was interrupted, re-parse

        parse_result = parse_file(str(file_path), content)
        if parse_result.language == "unknown":
            return None

        file_id = self.repository.upsert_file(
            path=str(file_path),
            language=parse_result.language,
            file_hash=file_hash,
            lines_of_code=len(content.splitlines()),
            tokens=max(1, len(content) // 4),
            version_id=self.version_id,
        )
        return file_id, parse_result

    def _resolve_dependency(
        self,
        file_path: Path,
        root: Path,
        language: str,
        dependency: DependencyRecord,
    ) -> DependencyRecord:
        resolved = dependency.resolved
        is_local = dependency.is_local

        if language == "python":
            resolved = self._resolve_python_import(file_path, root, dependency.path) or resolved
            is_local = bool(resolved) or is_local
        elif language in {"javascript", "typescript"}:
            resolved = self._resolve_javascript_import(file_path, root, dependency.path) or resolved
            is_local = dependency.path.startswith(".") or bool(resolved) or is_local

        return DependencyRecord(
            path=dependency.path,
            type=dependency.type,
            line_number=dependency.line_number,
            resolved=str(resolved) if resolved else "",
            is_local=is_local,
        )

    def _resolve_python_import(self, file_path: Path, root: Path, import_path: str) -> str:
        stripped = import_path.strip()
        if not stripped:
            return ""

        relative_levels = len(stripped) - len(stripped.lstrip("."))
        module_name = stripped.lstrip(".")
        base_dir = file_path.parent
        if relative_levels > 0:
            for _ in range(max(0, relative_levels - 1)):
                base_dir = base_dir.parent
            candidates = self._python_module_candidates(base_dir, module_name)
        else:
            candidates = self._python_module_candidates(file_path.parent, module_name)
            if root != file_path.parent:
                candidates.extend(self._python_module_candidates(root, module_name))

        for candidate in candidates:
            if candidate.exists() and candidate.is_file():
                return str(candidate.resolve())
        return ""

    def _python_module_candidates(self, base_dir: Path, module_name: str) -> list[Path]:
        if not module_name:
            return []
        relative_path = Path(*module_name.split("."))
        return [base_dir / f"{relative_path}.py", base_dir / relative_path / "__init__.py"]

    def _resolve_javascript_import(self, file_path: Path, root: Path, import_path: str) -> str:
        if not import_path.startswith((".", "..")):
            return ""
        base = (file_path.parent / import_path).resolve()
        candidates = [
            base,
            base.with_suffix(".js"),
            base.with_suffix(".jsx"),
            base.with_suffix(".ts"),
            base.with_suffix(".tsx"),
            base / "index.js",
            base / "index.ts",
        ]
        for candidate in candidates:
            if candidate.exists() and candidate.is_file() and (candidate == root or root in candidate.parents):
                return str(candidate)
        return ""
