import uuid
from graphlib import CycleError, TopologicalSorter
from pathlib import Path
from typing import TYPE_CHECKING

from kajson.kajson_manager import KajsonManager
from mthds.package.dependency_resolver import ResolvedDependency, resolve_all_dependencies
from mthds.package.discovery import find_package_manifest
from mthds.package.exceptions import DependencyResolveError, ManifestError
from mthds.package.manifest.schema import MTHDS_STANDARD_VERSION, MethodsManifest
from pydantic import BaseModel, ValidationError
from typing_extensions import override

from pipelex import log
from pipelex.builder import builder
from pipelex.core.bundles.pipelex_bundle_blueprint import PipelexBundleBlueprint
from pipelex.core.concepts.concept_factory import ConceptFactory
from pipelex.core.concepts.native.concept_native import NativeConceptCode
from pipelex.core.domains.domain import Domain
from pipelex.core.domains.domain_blueprint import DomainBlueprint
from pipelex.core.domains.domain_factory import DomainFactory
from pipelex.core.interpreter.exceptions import PipelexInterpreterError
from pipelex.core.interpreter.interpreter import PipelexInterpreter
from pipelex.core.pipes.pipe_abstract import PipeAbstract
from pipelex.core.pipes.pipe_factory import PipeFactory
from pipelex.core.stuffs.structured_content import StructuredContent
from pipelex.core.validation import report_validation_error
from pipelex.hub import get_current_library
from pipelex.libraries.exceptions import (
    LibraryError,
    LibraryLoadingError,
)
from pipelex.libraries.library import Library
from pipelex.libraries.library_factory import LibraryFactory
from pipelex.libraries.library_manager_abstract import LibraryManagerAbstract
from pipelex.libraries.library_utils import (
    get_pipelex_mthds_files_from_dirs,
)
from pipelex.libraries.pipe.exceptions import PipeLibraryError
from pipelex.libraries.visibility_utils import check_visibility_for_blueprints, make_visibility_checker
from pipelex.system.registries.class_registry_utils import ClassRegistryUtils
from pipelex.system.registries.func_registry_utils import FuncRegistryUtils
from pipelex.tools.misc.semver import SemVerError, parse_constraint, parse_version, version_satisfies

if TYPE_CHECKING:
    from pipelex.core.concepts.concept import Concept
    from pipelex.core.concepts.concept_blueprint import ConceptBlueprint
    from pipelex.core.domains.domain import Domain


class LibraryManager(LibraryManagerAbstract):
    def __init__(self):
        # UNTITLED library is the fallback library for all others
        self._libraries: dict[str, Library] = {}
        self._pipe_source_map: dict[str, Path] = {}  # pipe_code -> source .mthds file

    ############################################################
    # Manager lifecycle
    ############################################################
    def generate_library_id(self) -> str:
        return str(uuid.uuid4())

    @override
    def setup(self) -> None:
        pass

    @override
    def teardown(self, library_id: str | None = None) -> None:
        if library_id:
            if library_id not in self._libraries:
                msg = f"Trying to teardown a library that does not exist: '{library_id}'"
                raise LibraryError(msg)
            library = self._libraries[library_id]
            # Remove source map entries for pipes in this library
            for pipe_code in library.pipe_library.root:
                self._pipe_source_map.pop(pipe_code, None)
            library.teardown()
            del self._libraries[library_id]
            return

        for library in self._libraries.values():
            library.teardown()
        self._libraries = {}
        self._pipe_source_map = {}

    @override
    def reset(self) -> None:
        self.teardown()
        self.setup()

    @override
    def open_library(self, library_id: str | None = None) -> tuple[str, Library]:
        if not library_id:
            library_id = self.generate_library_id()

        if library_id in self._libraries:
            the_library = self._libraries[library_id]
        else:
            the_library = LibraryFactory.make_empty()
            self._libraries[library_id] = the_library

        return library_id, the_library

    ############################################################
    # Public library accessors
    ############################################################

    @override
    def get_library(self, library_id: str) -> Library:
        if library_id not in self._libraries:
            msg = f"Library '{library_id}' does not exist"
            raise LibraryError(msg)
        return self._libraries[library_id]

    @override
    def get_current_library(self) -> Library:
        library_id = get_current_library()
        if library_id not in self._libraries:
            msg = f"No current library set. Library '{library_id}' does not exist"
            raise LibraryError(msg)
        return self._libraries[library_id]

    @override
    def get_pipe_source(self, pipe_code: str) -> Path | None:
        """Get the source file path for a pipe.

        Args:
            pipe_code: The pipe code to look up.

        Returns:
            Path to the .mthds file the pipe was loaded from, or None if unknown.
        """
        return self._pipe_source_map.get(pipe_code)

    ############################################################
    # Private methods
    ############################################################

    @override
    def load_libraries(
        self,
        library_id: str,
        library_dirs: list[Path] | None = None,
        library_file_paths: list[Path] | None = None,
    ) -> list[PipeAbstract]:
        # Ensure libraries exist for this library_id
        if library_id not in self._libraries:
            msg = f"Trying to load a library that does not exist: '{library_id}'"
            raise LibraryError(msg)

        if not library_dirs:
            library_dirs = []

        all_dirs: list[Path] = []
        all_mthds_paths: list[Path] = []
        all_dirs.extend(library_dirs)
        all_mthds_paths.extend(get_pipelex_mthds_files_from_dirs(set(library_dirs)))

        if library_file_paths:
            all_mthds_paths.extend(library_file_paths)

        # Combine and deduplicate
        seen_absolute_paths: set[str] = set()
        valid_mthds_paths: list[Path] = []
        for mthds_path in all_mthds_paths:
            try:
                absolute_path = str(mthds_path.resolve())
            except (OSError, RuntimeError):
                # For paths that can't be resolved (e.g., in zipped packages), use string representation
                absolute_path = str(mthds_path)

            if absolute_path not in seen_absolute_paths:
                valid_mthds_paths.append(mthds_path)
                seen_absolute_paths.add(absolute_path)

        # Import modules and register in global registries
        # Import from user directories
        for library_dir in all_dirs:
            # Only import files that contain StructuredContent subclasses (uses AST pre-check)
            ClassRegistryUtils.import_modules_in_folder(
                folder_path=str(library_dir),
                base_class_names=[StructuredContent.__name__],
                force_include_dirs=[str(Path(builder.__file__).parent)],
            )
            # Only import files that contain @pipe_func decorated functions (uses AST pre-check)
            FuncRegistryUtils.register_funcs_in_folder(
                folder_path=str(library_dir),
                force_include_dirs=[str(Path(builder.__file__).parent)],
            )

        # Auto-discover and register all StructuredContent classes from sys.modules
        num_registered = ClassRegistryUtils.auto_register_all_subclasses(
            base_class=StructuredContent,
        )
        log.verbose(f"Auto-registered {num_registered} StructuredContent classes from loaded modules")

        # Load MTHDS files into the specific library
        log.verbose(f"Loading MTHDS files from: {[str(p) for p in valid_mthds_paths]}")
        return self._load_mthds_files_into_library(library_id=library_id, valid_mthds_paths=valid_mthds_paths)

    @override
    def load_libraries_concepts_only(
        self,
        library_id: str,
        library_dirs: list[Path] | None = None,
        library_file_paths: list[Path] | None = None,
    ) -> list["Concept"]:
        """Load only domains and concepts from library directories, skipping pipes.

        This is a lightweight alternative to load_libraries() that only processes
        domains and concepts. It does not load pipes, does not perform pipe validation,
        and does not run library.validate_library().

        Args:
            library_id: The ID of the library to load into
            library_dirs: List of directories containing MTHDS files
            library_file_paths: List of specific MTHDS file paths to load

        Returns:
            List of all concepts that were loaded
        """
        # Ensure libraries exist for this library_id
        if library_id not in self._libraries:
            msg = f"Trying to load a library that does not exist: '{library_id}'"
            raise LibraryError(msg)

        if not library_dirs:
            library_dirs = []

        all_dirs: list[Path] = []
        all_mthds_paths: list[Path] = []
        all_dirs.extend(library_dirs)
        all_mthds_paths.extend(get_pipelex_mthds_files_from_dirs(set(library_dirs)))

        if library_file_paths:
            all_mthds_paths.extend(library_file_paths)

        # Combine and deduplicate
        seen_absolute_paths: set[str] = set()
        valid_mthds_paths: list[Path] = []
        for mthds_path in all_mthds_paths:
            try:
                absolute_path = str(mthds_path.resolve())
            except (OSError, RuntimeError):
                # For paths that can't be resolved (e.g., in zipped packages), use string representation
                absolute_path = str(mthds_path)

            if absolute_path not in seen_absolute_paths:
                valid_mthds_paths.append(mthds_path)
                seen_absolute_paths.add(absolute_path)

        # Import modules and register in global registries
        # Import from user directories - still needed for StructuredContent classes
        for library_dir in all_dirs:
            # Only import files that contain StructuredContent subclasses (uses AST pre-check)
            ClassRegistryUtils.import_modules_in_folder(
                folder_path=str(library_dir),
                base_class_names=[StructuredContent.__name__],
                force_include_dirs=[str(Path(builder.__file__).parent)],
            )
            # NOTE: We skip FuncRegistryUtils.register_funcs_in_folder() since we're not loading pipes

        # Auto-discover and register all StructuredContent classes from sys.modules
        num_registered = ClassRegistryUtils.auto_register_all_subclasses(
            base_class=StructuredContent,
        )
        log.debug(f"Auto-registered {num_registered} StructuredContent classes from loaded modules")

        # Load MTHDS files as concepts only (no pipes)
        log.debug(f"Loading concepts only from MTHDS files: {[str(p) for p in valid_mthds_paths]}")
        library = self.get_library(library_id=library_id)
        all_concepts: list[Concept] = []
        for mthds_path in valid_mthds_paths:
            # Track loaded path (resolve if possible)
            try:
                resolved_path = mthds_path.resolve()
            except (OSError, RuntimeError):
                resolved_path = mthds_path
            library.loaded_mthds_paths.append(resolved_path)

            blueprint = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=mthds_path)
            concepts = self.load_concepts_only_from_blueprints(library_id=library_id, blueprints=[blueprint])
            all_concepts.extend(concepts)

        return all_concepts

    @override
    def load_from_blueprints(self, library_id: str, blueprints: list[PipelexBundleBlueprint]) -> list[PipeAbstract]:
        """Load domains, concepts, and pipes from a list of blueprints.

        Args:
            library_id: The ID of the library to load into
            blueprints: List of parsed MTHDS blueprints to load

        Returns:
            List of all pipes that were loaded
        """
        library = self.get_library(library_id=library_id)
        all_pipes: list[PipeAbstract] = []

        # Load all domains first
        all_domains: list[Domain] = []
        for blueprint in blueprints:
            domain = DomainFactory.make_from_blueprint(
                blueprint=DomainBlueprint(
                    source=blueprint.source,
                    code=blueprint.domain,
                    description=blueprint.description or "",
                    system_prompt=blueprint.system_prompt,
                ),
            )
            all_domains.append(domain)
        library.domain_library.add_domains(domains=all_domains)

        # Load concepts (forward references resolved after all are loaded)
        all_concepts = self._load_concepts_from_blueprints(blueprints)
        library.concept_library.add_concepts(concepts=all_concepts)

        # Resolve forward references in dynamically generated structure classes
        self._rebuild_models_with_forward_refs(all_concepts)

        # Detect cycles in concept references (A -> B -> A is forbidden)
        self._detect_concept_cycles(all_concepts)

        # Load all pipes, detecting duplicate declarations across bundles in this library
        pipe_source_in_this_load: dict[str, Path | None] = {}
        for blueprint in blueprints:
            pipes: list[PipeAbstract] = []
            new_source = Path(blueprint.source) if blueprint.source else None
            if blueprint.pipe is not None:
                for pipe_code, pipe_blueprint in blueprint.pipe.items():
                    # Detect duplicate pipe declarations across different bundles in the same library
                    if pipe_code in pipe_source_in_this_load:
                        existing_source = pipe_source_in_this_load[pipe_code]
                        if existing_source == new_source:
                            msg = (
                                f"Pipe '{pipe_code}' is declared twice in the same bundle file: '{existing_source}'. "
                                "Please remove the duplicate declaration."
                            )
                        else:
                            msg = (
                                f"Pipe '{pipe_code}' is declared in two different bundle files: "
                                f"'{existing_source}' and '{new_source}'. "
                                "Please remove one of the declarations or rename one of the pipes."
                            )
                        raise PipeLibraryError(msg)
                    pipe_source_in_this_load[pipe_code] = new_source
                    pipe = PipeFactory[PipeAbstract].make_from_blueprint(
                        domain_code=blueprint.domain,
                        pipe_code=pipe_code,
                        blueprint=pipe_blueprint,
                        concept_codes_from_the_same_domain=[the_concept.code for the_concept in all_concepts],
                    )
                    pipes.append(pipe)
                    # Track source file for this pipe (used by get_pipe_source)
                    if new_source:
                        self._pipe_source_map[pipe_code] = new_source
            all_pipes.extend(pipes)

        library.pipe_library.add_pipes(pipes=all_pipes)

        library.validate_library()
        return all_pipes

    @override
    def load_concepts_only_from_blueprints(
        self,
        library_id: str,
        blueprints: list[PipelexBundleBlueprint],
    ) -> list["Concept"]:
        """Load only domains and concepts from blueprints, skipping pipes.

        This is a lightweight alternative to load_from_blueprints() that only processes
        domains and concepts. It does not load pipes, does not perform pipe validation,
        and does not run library.validate_library().

        Args:
            library_id: The ID of the library to load into
            blueprints: List of parsed MTHDS blueprints to load

        Returns:
            List of all concepts that were loaded
        """
        library = self.get_library(library_id=library_id)

        # Load all domains first
        all_domains: list[Domain] = []
        for blueprint in blueprints:
            domain = DomainFactory.make_from_blueprint(
                blueprint=DomainBlueprint(
                    source=blueprint.source,
                    code=blueprint.domain,
                    description=blueprint.description or "",
                    system_prompt=blueprint.system_prompt,
                ),
            )
            all_domains.append(domain)
        library.domain_library.add_domains(domains=all_domains)

        # Load concepts (forward references resolved after all are loaded)
        all_concepts = self._load_concepts_from_blueprints(blueprints)
        library.concept_library.add_concepts(concepts=all_concepts)

        # Resolve forward references in dynamically generated structure classes
        self._rebuild_models_with_forward_refs(all_concepts)

        # Detect cycles in concept references (A -> B -> A is forbidden)
        self._detect_concept_cycles(all_concepts)

        library.validate_domain_library_with_libraries()
        library.validate_concept_library_with_libraries()

        return all_concepts

    def _load_concepts_from_blueprints(
        self,
        blueprints: list[PipelexBundleBlueprint],
    ) -> list["Concept"]:
        """Load concepts from blueprints in topological order based on refines dependencies.

        Concepts that refine other concepts are loaded after their base concepts,
        ensuring the base class is always registered before the refining class.
        Forward references between concepts (for structure fields) are resolved
        later by _rebuild_models_with_forward_refs().

        Args:
            blueprints: List of parsed MTHDS blueprints to load

        Returns:
            List of loaded concepts
        """
        # Step 1: Collect all concept entries with metadata
        ref_to_entry: dict[str, tuple[str, str, ConceptBlueprint | str]] = {}

        for blueprint in blueprints:
            if blueprint.concept is not None:
                for concept_code, concept_blueprint in blueprint.concept.items():
                    concept_ref = ConceptFactory.make_concept_ref_with_domain(
                        domain_code=blueprint.domain,
                        concept_code=concept_code,
                    )
                    ref_to_entry[concept_ref] = (
                        blueprint.domain,
                        concept_code,
                        concept_blueprint,
                    )

        # Step 2: Build dependency graph and topologically sort using graphlib
        sorter: TopologicalSorter[str] = TopologicalSorter()

        for concept_ref, (
            domain_code,
            _concept_code,
            concept_blueprint,
        ) in ref_to_entry.items():
            dependencies: set[str] = set()

            if not isinstance(concept_blueprint, str) and concept_blueprint.refines:
                refines = concept_blueprint.refines
                # Skip native concepts (always available)
                if not NativeConceptCode.is_native_concept_ref_or_code(concept_ref_or_code=refines):
                    # Resolve to full concept ref
                    if "." in refines:
                        refined_ref = refines
                    else:
                        refined_ref = ConceptFactory.make_concept_ref_with_domain(
                            domain_code=domain_code,
                            concept_code=refines,
                        )
                    # Only add dependency if it's a concept we're loading
                    if refined_ref in ref_to_entry:
                        dependencies.add(refined_ref)

            sorter.add(concept_ref, *dependencies)

        # Step 3: Get sorted order (raises CycleError if cycles detected)
        try:
            sorted_refs = list(sorter.static_order())
        except CycleError as exc:
            msg = f"Cycle detected in concept refines dependencies: {exc.args[1]}"
            raise LibraryLoadingError(msg) from exc

        # Step 4: Load concepts in topological order
        all_concepts: list[Concept] = []
        for concept_ref in sorted_refs:
            domain_code, concept_code, concept_blueprint = ref_to_entry[concept_ref]
            concept = ConceptFactory.make_from_blueprint(
                domain_code=domain_code,
                concept_code=concept_code,
                blueprint_or_string_description=concept_blueprint,
            )
            all_concepts.append(concept)

        return all_concepts

    ############################################################
    # Private helper methods
    ############################################################

    def _load_mthds_files_into_library(self, library_id: str, valid_mthds_paths: list[Path]) -> list[PipeAbstract]:
        """Load MTHDS files into a specific library.

        This method:
        1. Parses blueprints from MTHDS files
        2. Finds and loads dependency packages (if manifest has dependencies with local paths)
        3. Loads blueprints into the specified library

        Args:
            library_id: The ID of the library to load into
            valid_mthds_paths: List of MTHDS file paths to load
        """
        blueprints: list[PipelexBundleBlueprint] = []
        for mthds_file_path in valid_mthds_paths:
            try:
                blueprint = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=mthds_file_path)
                blueprint.source = str(mthds_file_path)
            except FileNotFoundError as file_not_found_error:
                msg = f"Could not find MTHDS bundle at '{mthds_file_path}'"
                raise LibraryLoadingError(msg) from file_not_found_error
            except PipelexInterpreterError as interpreter_error:
                # Forward BLUEPRINT validation errors from interpreter
                msg = f"Could not load MTHDS bundle from '{mthds_file_path}' because of: {interpreter_error.message}"
                raise LibraryLoadingError(
                    message=msg,
                    blueprint_validation_errors=interpreter_error.validation_errors,
                ) from interpreter_error
            blueprints.append(blueprint)

        # Find manifest and run package visibility validation
        manifest = self._check_package_visibility(blueprints=blueprints, mthds_paths=valid_mthds_paths)

        # Warn if the package requires a newer MTHDS standard version
        if manifest is not None and manifest.mthds_version is not None:
            self._warn_if_mthds_version_unsatisfied(
                mthds_version_constraint=manifest.mthds_version,
                package_address=manifest.address,
            )

        # Load dependency packages if manifest has local-path dependencies
        if manifest is not None and getattr(manifest, "dependencies", None):
            package_root = self._find_package_root(mthds_paths=valid_mthds_paths)
            if package_root is not None:
                self._load_dependency_packages(
                    library_id=library_id,
                    manifest=manifest,
                    package_root=package_root,
                )

        # Store resolved absolute paths for duplicate detection in the library
        library = self.get_library(library_id=library_id)
        for mthds_file_path in valid_mthds_paths:
            try:
                resolved_path = mthds_file_path.resolve()
            except (OSError, RuntimeError):
                resolved_path = mthds_file_path
            library.loaded_mthds_paths.append(resolved_path)

        try:
            return self.load_from_blueprints(library_id=library_id, blueprints=blueprints)
        except ValidationError as validation_error:
            validation_error_msg = report_validation_error(category="mthds", validation_error=validation_error)
            msg = f"Could not load blueprints from {[str(pth) for pth in valid_mthds_paths]} because of: {validation_error_msg}"
            raise LibraryError(
                message=msg,
            ) from validation_error

    def _warn_if_mthds_version_unsatisfied(
        self,
        mthds_version_constraint: str,
        package_address: str,
    ) -> None:
        """Emit a warning if the current MTHDS standard version does not satisfy the package's constraint."""
        try:
            constraint = parse_constraint(mthds_version_constraint)
            current_version = parse_version(MTHDS_STANDARD_VERSION)
        except SemVerError as exc:
            log.warning(f"Could not parse mthds_version constraint '{mthds_version_constraint}' for package '{package_address}': {exc}")
            return

        if not version_satisfies(current_version, constraint):
            log.warning(
                f"Package '{package_address}' requires MTHDS standard version "
                f"'{mthds_version_constraint}', but the current version is "
                f"'{MTHDS_STANDARD_VERSION}'. Some features may not work correctly."
            )

    def _check_package_visibility(
        self,
        blueprints: list[PipelexBundleBlueprint],
        mthds_paths: list[Path],
    ) -> MethodsManifest | None:
        """Check package visibility if a METHODS.toml manifest exists.

        Walks up from the first bundle path to find a METHODS.toml manifest.
        If found, validates all cross-domain pipe references against the exports.

        Args:
            blueprints: The parsed bundle blueprints
            mthds_paths: The MTHDS file paths that were loaded

        Returns:
            The manifest if found, or None
        """
        if not mthds_paths:
            return None

        # Try to find a manifest from the first bundle path
        try:
            manifest = find_package_manifest(mthds_paths[0])
        except ManifestError as exc:
            log.warning(f"Could not parse METHODS.toml: {exc.message}")
            # Still enforce reserved domains even when manifest is unparseable
            checker = make_visibility_checker(manifest=None, blueprints=blueprints)
            reserved_errors = checker.validate_reserved_domains()
            if reserved_errors:
                error_messages = [err.message for err in reserved_errors]
                joined_errors = "\n  - ".join(error_messages)
                msg = f"Reserved domain violations found:\n  - {joined_errors}"
                raise LibraryLoadingError(msg) from exc
            return None

        if manifest is None:
            # Still enforce reserved domains even for standalone bundles
            checker = make_visibility_checker(manifest=None, blueprints=blueprints)
            reserved_errors = checker.validate_reserved_domains()
            if reserved_errors:
                error_messages = [err.message for err in reserved_errors]
                joined_errors = "\n  - ".join(error_messages)
                msg = f"Reserved domain violations found:\n  - {joined_errors}"
                raise LibraryLoadingError(msg)
            return None

        visibility_errors = check_visibility_for_blueprints(manifest=manifest, blueprints=blueprints)
        if visibility_errors:
            error_messages = [err.message for err in visibility_errors]
            joined_errors = "\n  - ".join(error_messages)
            msg = f"Package visibility violations found:\n  - {joined_errors}"
            raise LibraryLoadingError(msg)

        return manifest

    def _find_package_root(self, mthds_paths: list[Path]) -> Path | None:
        """Find the package root directory by walking up from the first .mthds file.

        The package root is the directory containing METHODS.toml.

        Args:
            mthds_paths: The MTHDS file paths

        Returns:
            The package root path, or None
        """
        if not mthds_paths:
            return None

        current = mthds_paths[0].parent.resolve()
        while True:
            manifest_path = current / "METHODS.toml"
            if manifest_path.is_file():
                return current

            git_dir = current / ".git"
            if git_dir.exists():
                return None

            parent = current.parent
            if parent == current:
                return None
            current = parent

    def _load_dependency_packages(
        self,
        library_id: str,
        manifest: MethodsManifest,
        package_root: Path,
    ) -> None:
        """Load dependency packages into the library.

        Resolves local-path dependencies, parses their blueprints, and loads
        their concepts and exported pipes into isolated child libraries.
        Aliased entries are also added to the main library for backward-compatible lookups.

        Args:
            library_id: The library to load into
            manifest: The consuming package's manifest
            package_root: The root directory of the consuming package
        """
        try:
            resolved_deps = resolve_all_dependencies(manifest=manifest, package_root=package_root)
        except DependencyResolveError as exc:
            msg = f"Failed to resolve dependencies: {exc}"
            raise LibraryLoadingError(msg) from exc

        library = self.get_library(library_id=library_id)

        for resolved_dep in resolved_deps:
            self._load_single_dependency(
                library=library,
                resolved_dep=resolved_dep,
            )

        # Wire concept resolver after all deps are loaded so cross-package
        # refinement checks can traverse into child libraries
        library.concept_library.set_concept_resolver(library.resolve_concept)

    def _load_single_dependency(
        self,
        library: Library,
        resolved_dep: ResolvedDependency,
    ) -> None:
        """Load a single resolved dependency into an isolated child library.

        Creates a child Library for the dependency, loads domains/concepts/pipes
        into it, registers it in library.dependency_libraries, and adds aliased
        entries to the main library for backward-compatible cross-package lookups.

        Args:
            library: The main library to load into
            resolved_dep: The resolved dependency info
        """
        alias = resolved_dep.alias

        # Parse dependency blueprints
        dep_blueprints: list[PipelexBundleBlueprint] = []
        for mthds_path in resolved_dep.mthds_files:
            try:
                blueprint = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=mthds_path)
                blueprint.source = str(mthds_path)
            except (FileNotFoundError, PipelexInterpreterError) as exc:
                log.warning(f"Could not parse dependency '{alias}' bundle '{mthds_path}': {exc}")
                continue
            dep_blueprints.append(blueprint)

        if not dep_blueprints:
            log.warning(f"No valid blueprints found for dependency '{alias}'")
            return

        # Warn if the dependency requires a newer MTHDS standard version
        if resolved_dep.manifest is not None and resolved_dep.manifest.mthds_version is not None:
            self._warn_if_mthds_version_unsatisfied(
                mthds_version_constraint=resolved_dep.manifest.mthds_version,
                package_address=resolved_dep.address,
            )

        # Create isolated child library for this dependency
        child_library = LibraryFactory.make_empty()

        # Load domains into child library
        all_domains: list[Domain] = []
        for blueprint in dep_blueprints:
            domain = DomainFactory.make_from_blueprint(
                blueprint=DomainBlueprint(
                    source=blueprint.source,
                    code=blueprint.domain,
                    description=blueprint.description or "",
                    system_prompt=blueprint.system_prompt,
                ),
            )
            all_domains.append(domain)
        child_library.domain_library.add_domains(domains=all_domains)

        # Load concepts into child library
        dep_concepts = self._load_concepts_from_blueprints(dep_blueprints)
        child_library.concept_library.add_concepts(concepts=dep_concepts)

        # Collect main_pipes for auto-export
        main_pipes: set[str] = set()
        for blueprint in dep_blueprints:
            if blueprint.main_pipe:
                main_pipes.add(blueprint.main_pipe)

        # Determine if we filter by exports or load all.
        # exported_pipe_codes is None when no manifest exists (all pipes public),
        # or a set (possibly empty) when a manifest defines exports.
        if resolved_dep.exported_pipe_codes is None:
            # No manifest: all pipes are public, no filtering
            has_exports = False
            all_exported: set[str] = set()
        else:
            # Manifest exists: filter to exported pipes + main_pipes
            has_exports = True
            all_exported = resolved_dep.exported_pipe_codes | main_pipes

        # Temporarily register dep concepts in main library for pipe construction
        # (PipeFactory resolves concepts through the hub's current library)
        temp_concept_refs: list[str] = []
        for concept in dep_concepts:
            if not library.concept_library.is_concept_exists(concept_ref=concept.concept_ref):
                library.concept_library.add_new_concept(concept=concept)
                temp_concept_refs.append(concept.concept_ref)

        # Load exported pipes into child library, ensuring temp concepts are
        # always cleaned up even if an unexpected exception occurs
        try:
            concept_codes = [concept.code for concept in dep_concepts]
            for blueprint in dep_blueprints:
                if blueprint.pipe is None:
                    continue
                for pipe_code, pipe_blueprint in blueprint.pipe.items():
                    # If manifest has exports, only load exported pipes
                    if has_exports and pipe_code not in all_exported:
                        continue
                    try:
                        pipe = PipeFactory[PipeAbstract].make_from_blueprint(
                            domain_code=blueprint.domain,
                            pipe_code=pipe_code,
                            blueprint=pipe_blueprint,
                            concept_codes_from_the_same_domain=concept_codes,
                        )
                        child_library.pipe_library.add_new_pipe(pipe=pipe)
                    except (PipeLibraryError, ValidationError) as exc:
                        log.warning(f"Could not load dependency '{alias}' pipe '{pipe_code}': {exc}")
        finally:
            # Remove temporary concept entries from main library
            library.concept_library.remove_concepts_by_concept_refs(concept_refs=temp_concept_refs)

        # Register child library for isolation
        library.dependency_libraries[alias] = child_library

        # Add aliased entries to main library for backward-compatible cross-package lookups
        for concept in dep_concepts:
            library.concept_library.add_dependency_concept(alias=alias, concept=concept)

        for pipe in child_library.pipe_library.get_pipes():
            library.pipe_library.add_dependency_pipe(alias=alias, pipe=pipe)

        log.verbose(f"Loaded dependency '{alias}': {len(dep_concepts)} concepts, pipes from {len(dep_blueprints)} bundles")

    def _remove_pipes_from_blueprint(self, blueprint: PipelexBundleBlueprint) -> None:
        library = self.get_current_library()
        if blueprint.pipe is not None:
            library.pipe_library.remove_pipes_by_codes(pipe_codes=list(blueprint.pipe.keys()))

    def _remove_concepts_from_blueprint(self, blueprint: PipelexBundleBlueprint) -> None:
        library = self.get_current_library()
        if blueprint.concept is not None:
            concept_codes_to_remove = [
                ConceptFactory.make_concept_ref_with_domain(domain_code=blueprint.domain, concept_code=concept_code)
                for concept_code in blueprint.concept
            ]
            library.concept_library.remove_concepts_by_concept_refs(concept_refs=concept_codes_to_remove)

    @override
    def _remove_from_blueprint(self, library_id: str, blueprint: PipelexBundleBlueprint) -> None:
        self._remove_pipes_from_blueprint(blueprint=blueprint)
        self._remove_concepts_from_blueprint(blueprint=blueprint)

    @override
    def _remove_from_blueprints(self, library_id: str, blueprints: list[PipelexBundleBlueprint]) -> None:
        for blueprint in blueprints:
            self._remove_from_blueprint(library_id=library_id, blueprint=blueprint)

    def _rebuild_models_with_forward_refs(self, concepts: list["Concept"]) -> None:
        """Rebuild Pydantic models to resolve forward references.

        When dynamically generated classes have forward references (e.g., `customer: "Customer"`),
        Python's get_type_hints() cannot resolve them because the referenced classes are not
        in any accessible namespace. This method builds a namespace with all structure classes
        and calls model_rebuild() to resolve the forward references.

        Args:
            concepts: List of concepts that were just loaded
        """
        # Build namespace with all structure class names
        namespace: dict[str, type] = {}
        class_registry = KajsonManager.get_class_registry()

        for concept in concepts:
            structure_class = class_registry.get_class(name=concept.structure_class_name)
            if structure_class is not None:
                namespace[concept.structure_class_name] = structure_class
                # Also add by concept code in case the forward ref uses the code
                namespace[concept.code] = structure_class

        # Rebuild each model with the shared namespace
        for concept in concepts:
            structure_class = class_registry.get_class(name=concept.structure_class_name)
            if structure_class is not None and issubclass(structure_class, BaseModel):
                try:
                    structure_class.model_rebuild(_types_namespace=namespace)
                except Exception as exc:
                    log.debug(f"Could not rebuild model for {concept.concept_ref}: {exc}")

    def _detect_concept_cycles(self, concepts: list["Concept"]) -> None:
        """Detect cycles in concept references and raise an error if found.

        Cycles like A -> B -> A are forbidden because they create infinite recursion
        in the data structure. This method traverses each concept's structure fields
        and detects if any chain of references leads back to an already-visited concept.

        Args:
            concepts: List of concepts to check for cycles
        """
        # TODO: Refactor to inspect ConceptStructureBlueprint directly (concept_ref and item_concept_ref fields)
        # instead of the generated Python types. This would be more direct and wouldn't depend on
        # how types are generated (e.g., Optional wrappers for non-required fields).
        class_registry = KajsonManager.get_class_registry()

        # Build mappings from class names to concept refs
        class_to_concept: dict[str, str] = {}
        for concept in concepts:
            class_to_concept[concept.structure_class_name] = concept.concept_ref
            class_to_concept[concept.code] = concept.concept_ref

        def get_referenced_concepts(concept: "Concept") -> list[str]:
            """Get concept refs that this concept references in its structure fields."""
            refs: list[str] = []
            structure_class = class_registry.get_class(name=concept.structure_class_name)
            if structure_class is None or not issubclass(structure_class, BaseModel):
                return refs

            def extract_type_names(annotation: type) -> list[str]:
                """Recursively extract type names from possibly nested generic types."""
                if annotation is type(None):
                    return []

                type_names: list[str] = []
                origin = getattr(annotation, "__origin__", None)
                args = getattr(annotation, "__args__", ())

                if origin is None:
                    # Simple type, get its name
                    type_name = getattr(annotation, "__name__", None)
                    if type_name:
                        type_names.append(type_name)
                else:
                    # Generic type (like Optional[X], list[X], Union[X, Y], etc.)
                    # Recursively extract from all type arguments
                    for arg in args:
                        type_names.extend(extract_type_names(arg))

                return type_names

            for field_info in structure_class.model_fields.values():
                annotation = field_info.annotation
                if annotation is None:
                    continue

                # Recursively extract all type names from the annotation
                for type_name in extract_type_names(annotation):
                    if type_name in class_to_concept:
                        refs.append(class_to_concept[type_name])

            return refs

        def check_for_cycle(concept_ref: str, visiting: set[str], path: list[str]) -> None:
            """Recursively check for cycles starting from concept_ref."""
            if concept_ref in visiting:
                # Found a cycle - build error message
                cycle_start = path.index(concept_ref)
                cycle = [*path[cycle_start:], concept_ref]
                cycle_str = " -> ".join(cycle)
                msg = f"Cycle detected in concept references: {cycle_str}"
                raise LibraryLoadingError(msg)

            # Find the concept by ref
            concept = next((c for c in concepts if c.concept_ref == concept_ref), None)
            if concept is None:
                return  # External concept, skip

            visiting.add(concept_ref)
            path.append(concept_ref)

            for ref in get_referenced_concepts(concept):
                check_for_cycle(ref, visiting, path)

            path.pop()
            visiting.remove(concept_ref)

        # Check each concept as a starting point
        for concept in concepts:
            check_for_cycle(concept.concept_ref, set(), [])
