import ast
import asyncio
import hashlib
import importlib.util
import inspect
import json
import logging
import sys
from pathlib import Path
from typing import Any

import chromadb

from .adapters import ChromaVectorStore
from .config import MageToolsConfig, get_config
from .constants import COLLECTION_ATTR_NAME, GRIMORIUMS_INDEX_NAME
from .interfaces import EmbeddingProviderProtocol, VectorStoreProtocol

# from .spell_registry import spell_registry  <-- Removed global dependency

logger = logging.getLogger(__name__)


class SpellSync:
    """A magical synchronizer for matching and managing spells using Portable Spellbooks.

    Each subdirectory in the grimorium acts as a self-contained 'Grimorium' (Cartridge),
    containing its own ChromaDB database.
    """

    def __init__(
        self,
        root_path: Path | None = None,
        allowed_collections: list[str] | None = None,
        embedding_provider: EmbeddingProviderProtocol | None = None,
        vector_store: VectorStoreProtocol | None = None,
        config: MageToolsConfig | None = None,
    ):
        """Initialize the SpellSync with a single unified database.

        Args:
            root_path: Optional path to the project root containing .magetools.
                      If None, defaults to CWD or config.root_path.
            allowed_collections: Optional list of collection names to restrict access to.
                               If None, all collections are accessible.
            config: Optional MageToolsConfig object.
        """
        self.config = config or get_config(root_path=root_path)
        self.top_spells = 5
        # Distance threshold for filtering (Lower is better for distance metrics)
        self.distance_threshold = 0.4
        self.allowed_collections = allowed_collections
        self.registry = {}

        # Use root from config
        self.MAGETOOLS_ROOT = self.config.magetools_root
        db_path = self.config.db_path

        # Ensure root grimorium folder exists
        if not self.MAGETOOLS_ROOT.exists():
            pass

        # Dependency Injection / Defaults
        if embedding_provider is None:
            from .adapters import get_default_provider

            self.embedding_provider = get_default_provider(config=self.config)
        else:
            self.embedding_provider = embedding_provider

        if vector_store is None:
            self.vector_store = ChromaVectorStore(path=str(db_path))
        else:
            self.vector_store = vector_store

        self.embedding_function = self.embedding_provider.get_embedding_function()

    def __getstate__(self):
        """Custom pickling to exclude unpickleable objects."""
        state = self.__dict__.copy()
        if "client" in state:
            del state["client"]
        if "embedding_function" in state:
            del state["embedding_function"]
        return state

    def __setstate__(self, state):
        """Restore state and re-initialize unpickleable objects."""
        self.__dict__.update(state)
        # Re-initialize
        db_path = self.MAGETOOLS_ROOT / self.DB_FOLDER_NAME
        self.client = chromadb.PersistentClient(path=str(db_path))

    def get_grimorium_collection(self, collection_name: str):
        """Get or create a collection for a specific grimorium (folder)."""
        return self.vector_store.get_or_create_collection(
            name=collection_name,
            embedding_function=self.embedding_function,
        )

    def find_matching_spells(self, query: str) -> list[str]:
        """Find spells that match the given query across all valid collections."""
        if not query or not isinstance(query, str) or not query.strip():
            logger.error("Error: Invalid query")
            return []

        logger.info(f"Searching for spells matching: {query[:50]}...")
        all_matches = []

        # List all collections in the DB
        # This is strictly faster than iterating the filesystem
        try:
            collections = self.vector_store.list_collections()
        except Exception as e:
            logger.error(f"Failed to list collections: {e}")
            return []

        for collection_obj in collections:
            coll_name = collection_obj.name

            # Filter by allowed_collections if set
            if self.allowed_collections is not None:
                if coll_name not in self.allowed_collections:
                    continue

            try:
                # We need to get the collection object with our embedding function attached
                # list_collections returns light objects without the EF
                collection = self.vector_store.get_collection(
                    name=coll_name, embedding_function=self.embedding_function
                )

                results = collection.query(
                    query_texts=[query],
                    n_results=self.top_spells,
                    include=["documents", "distances"],
                )

                if results and results["ids"] and results["ids"][0]:
                    ids = results["ids"][0]
                    dists = results["distances"][0]

                    for i, spell_id in enumerate(ids):
                        dist = dists[i]
                        all_matches.append((spell_id, dist))

            except Exception as e:
                logger.warning(f"Failed to search collection '{coll_name}': {e}")

        # Deduplicate matches keeping the lowest distance
        unique_matches_map = {}
        for spell_id, dist in all_matches:
            if (
                spell_id not in unique_matches_map
                or dist < unique_matches_map[spell_id]
            ):
                unique_matches_map[spell_id] = dist

        # Sort by distance
        sorted_matches = sorted(unique_matches_map.items(), key=lambda x: x[1])

        if sorted_matches:
            logger.debug(f"Matches before filtering (name, distance): {sorted_matches}")

        # Filter by threshold logic
        filtered_matches = [
            match for match in sorted_matches if match[1] <= self.distance_threshold
        ]

        # Near-miss reporting for debug mode
        if self.config.debug:
            near_misses = [
                match
                for match in sorted_matches
                if self.distance_threshold < match[1] <= self.distance_threshold + 0.2
            ]
            if near_misses:
                logger.info(f"Near-miss spells (just above threshold): {near_misses}")

        # Return just the spell IDs (limited by top_spells)
        return [match[0] for match in filtered_matches][: self.top_spells]

    def find_relevant_grimoriums(self, query: str) -> list[dict[str, Any]]:
        """Find Grimoriums (Collections) that match the query."""
        if not query:
            return []

        logger.info(f"Searching for Grimoriums matching: {query}...")
        try:
            master_index = self.vector_store.get_or_create_collection(
                name=GRIMORIUMS_INDEX_NAME, embedding_function=self.embedding_function
            )

            results = master_index.query(
                query_texts=[query],
                n_results=self.top_spells,  # reuse top_spells limit for now
                include=["documents", "metadatas", "distances"],
            )

            matches = []
            if results and results["ids"] and results["ids"][0]:
                for i, g_id in enumerate(results["ids"][0]):
                    dist = results["distances"][0][i]
                    if dist <= self.distance_threshold:
                        meta = results["metadatas"][0][i]
                        doc = results["documents"][0][i]
                        matches.append(
                            {
                                "grimorium_id": g_id,
                                "description": doc,
                                "metadata": meta,
                                "distance": dist,
                            }
                        )

            return sorted(matches, key=lambda x: x["distance"])

        except Exception as e:
            logger.error(f"Failed to search grimoriums: {e}")
            return []

    def find_spells_within_grimorium(self, grimorium_id: str, query: str) -> list[str]:
        """Find spells within a specific Grimorium."""
        logger.info(f"Searching for '{query}' in Grimorium '{grimorium_id}'...")

        # Verify it's an allowed collection/grimorium
        if self.allowed_collections and grimorium_id not in self.allowed_collections:
            logger.warning(f"Access denied to Grimorium '{grimorium_id}'")
            return []

        try:
            collection = self.vector_store.get_collection(
                name=grimorium_id, embedding_function=self.embedding_function
            )

            results = collection.query(
                query_texts=[query], n_results=self.top_spells, include=["distances"]
            )

            matches = []
            if results and results["ids"] and results["ids"][0]:
                for i, spell_id in enumerate(results["ids"][0]):
                    dist = results["distances"][0][i]
                    if dist <= self.distance_threshold:
                        matches.append(spell_id)

            return matches

        except Exception as e:
            logger.error(f"Failed to search inside Grimorium '{grimorium_id}': {e}")
            return []

    def validate_spell_access(self, spell_name: str) -> bool:
        """Check if a spell is allowed to be accessed by this instance."""
        # If no restrictions, everything is allowed
        if self.allowed_collections is None:
            return True

        # Use lists of collections to check (cache this?)
        # For now, query the DB to be sure it exists in an allowed collection
        try:
            for coll_name in self.allowed_collections:
                try:
                    collection = self.vector_store.get_collection(
                        name=coll_name, embedding_function=self.embedding_function
                    )
                    # Use get to check existence efficiently
                    res = collection.get(ids=[spell_name], include=[])
                    if res and res["ids"]:
                        return True
                except Exception:
                    continue

            logger.warning(
                f"Access denied: Spell '{spell_name}' not found in allowed collections: {self.allowed_collections}"
            )
            return False

        except Exception as e:
            logger.error(f"Error validating spell access: {e}")
            return False

    def sync_grimoriums_metadata(self):
        """Synchronizes high-level Grimorium metadata to the master index."""
        logger.info("Syncing Grimorium metadata...")

        # Get the master index collection
        master_index = self.vector_store.get_or_create_collection(
            name=GRIMORIUMS_INDEX_NAME, embedding_function=self.embedding_function
        )

        # Iterate through known collections (buckets)
        # We can reuse the logic from sync_spells or simple filesystem iteration
        # For now, let's walk the filesystem again to capture descriptions

        folders = [
            d
            for d in self.MAGETOOLS_ROOT.iterdir()
            if d.is_dir()
            and not d.name.startswith((".", "_"))
            and d.name != self.config.db_folder_name
        ]

        ids = []
        documents = []
        metadatas = []

        for folder in folders:
            grimorium_id = folder.name
            current_hash = self._compute_grimorium_hash(folder)

            # Check for existing summary file
            summary_path = folder / "grimorium_summary.md"
            description = ""

            # Check if we have a stored hash in the index
            stored_hash = ""
            existing_results = master_index.get(ids=[grimorium_id])
            if existing_results and existing_results["metadatas"]:
                stored_hash = existing_results["metadatas"][0].get("hash", "")

            # If hash changed, we consider it "missing" to trigger re-generation
            is_stale = stored_hash and stored_hash != current_hash

            if summary_path.exists() and not is_stale:
                description = summary_path.read_text(encoding="utf-8")

            # If missing, empty, or stale, generate it!
            if not description or is_stale:
                if is_stale:
                    logger.info(
                        f"Summary for {grimorium_id} is stale. Re-generating..."
                    )
                else:
                    logger.info(
                        f"Auto-generating summary for Grimorium: {grimorium_id}"
                    )

                spell_docs = []
                # Gather docstrings from all spells in this folder
                for py_file in folder.rglob("*.py"):
                    if py_file.name.startswith((".", "_")):
                        continue
                    try:
                        source = py_file.read_text(encoding="utf-8")
                        module = ast.parse(source)
                        # Extract module docstring
                        module_doc = ast.get_docstring(module)
                        if module_doc:
                            spell_docs.append(f"Module {py_file.stem}: {module_doc}")

                        # Extract function and class docstrings
                        for node in ast.walk(module):
                            if isinstance(
                                node,
                                (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
                            ):
                                doc = ast.get_docstring(node)
                                if doc:
                                    name = node.name
                                    spell_docs.append(f"Spell {name}: {doc}")
                    except Exception as e:
                        logger.warning(f"Failed to parse {py_file} for summary: {e}")
                        continue

                if spell_docs:
                    description = self._generate_grimorium_summary(
                        grimorium_id, spell_docs
                    )
                    # Persist it
                    try:
                        summary_path.write_text(description, encoding="utf-8")
                    except Exception as e:
                        logger.error(f"Failed to write summary for {grimorium_id}: {e}")

            if not description:
                description = f"Collection of spells in {grimorium_id}"

            ids.append(grimorium_id)
            documents.append(description)
            metadatas.append(
                {
                    "grimorium_id": grimorium_id,
                    "spell_count": len(list(folder.glob("*.py"))),  # Rough count
                    "hash": current_hash,
                }
            )

        if ids:
            master_index.upsert(ids=ids, documents=documents, metadatas=metadatas)
            logger.info(f"Updated metadata for {len(ids)} Grimoriums.")

    async def sync_grimoriums_metadata_async(self, concurrency: int = 5):
        """Async version of sync_grimoriums_metadata with parallel LLM calls."""
        logger.info("Syncing Grimorium metadata (async)...")

        master_index = self.vector_store.get_or_create_collection(
            name=GRIMORIUMS_INDEX_NAME, embedding_function=self.embedding_function
        )

        folders = [
            d
            for d in self.MAGETOOLS_ROOT.iterdir()
            if d.is_dir()
            and not d.name.startswith((".", "_"))
            and d.name != self.config.db_folder_name
        ]

        semaphore = asyncio.Semaphore(concurrency)

        async def process_folder(folder: Path) -> tuple[str, str, dict] | None:
            async with semaphore:
                grimorium_id = folder.name
                current_hash = self._compute_grimorium_hash(folder)
                summary_path = folder / "grimorium_summary.md"
                description = ""

                # Check stored hash
                stored_hash = ""
                existing_results = master_index.get(ids=[grimorium_id])
                if existing_results and existing_results["metadatas"]:
                    stored_hash = existing_results["metadatas"][0].get("hash", "")

                is_stale = stored_hash and stored_hash != current_hash

                if summary_path.exists() and not is_stale:
                    description = summary_path.read_text(encoding="utf-8")

                if not description or is_stale:
                    logger.info(f"Generating summary for {grimorium_id}...")
                    spell_docs = self._extract_spell_docs(folder)
                    if spell_docs:
                        description = await asyncio.to_thread(
                            self._generate_grimorium_summary, grimorium_id, spell_docs
                        )
                        try:
                            summary_path.write_text(description, encoding="utf-8")
                        except Exception as e:
                            logger.error(
                                f"Failed to write summary for {grimorium_id}: {e}"
                            )

                if not description:
                    description = f"Collection of spells in {grimorium_id}"

                return (
                    grimorium_id,
                    description,
                    {
                        "grimorium_id": grimorium_id,
                        "spell_count": len(list(folder.glob("*.py"))),
                        "hash": current_hash,
                    },
                )

        results = await asyncio.gather(*[process_folder(f) for f in folders])

        ids, documents, metadatas = [], [], []
        for result in results:
            if result:
                ids.append(result[0])
                documents.append(result[1])
                metadatas.append(result[2])

        if ids:
            master_index.upsert(ids=ids, documents=documents, metadatas=metadatas)
            logger.info(f"Updated metadata for {len(ids)} Grimoriums (async).")

    def _extract_spell_docs(self, folder: Path) -> list[str]:
        """Extract docstrings from python files in a folder."""
        spell_docs = []
        for py_file in folder.rglob("*.py"):
            if py_file.name.startswith((".", "_")):
                continue
            try:
                source = py_file.read_text(encoding="utf-8")
                module = ast.parse(source)
                module_doc = ast.get_docstring(module)
                if module_doc:
                    spell_docs.append(f"Module {py_file.stem}: {module_doc}")
                for node in ast.walk(module):
                    if isinstance(
                        node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
                    ):
                        doc = ast.get_docstring(node)
                        if doc:
                            spell_docs.append(f"Spell {node.name}: {doc}")
            except Exception as e:
                logger.warning(f"Failed to parse {py_file} for summary: {e}")
        return spell_docs

    def _generate_grimorium_summary(
        self, grimorium_name: str, spell_docs: list[str]
    ) -> str:
        """Uses the AI Provider to generate a high-quality summary of the Grimorium."""
        prompt = f"""
Analyze the provided list of tools/functions in the '{grimorium_name}' collection.
Task: You are an expert in information extraction and technical documentation analysis. 
Generate a professional, high-density technical summary that distills collection capabilities while excluding operational details.

Requirements:
1. Identify the primary functional domains (e.g., File I/O, Authentication, Data Processing).
2. If the tools are a "ball of spells" (disjointed/random), categorize them into logical thematic clusters.
3. Use a neutral, technical tone. Avoid all fantasy, magical, or flowery language. 
4. Focus on what an agent can *do* with these tools.
5. Do NOT include installation, configuration, or unrelated operational details.

Follow this exact Markdown format:

# Domains
[Comma-separated list of technical areas covered]

# Summary
[2-3 sentence technical overview of the collection's purpose]

# Major Capabilities
- **[Theme 1]**: [Brief description of what tools in this theme achieve]
- **[Theme 2]**: [Brief description of what tools in this theme achieve]

# Key Search Keywords
[List 5-10 keywords an agent might use to find this collection, these can be tool specific or general domain keywords]

Tools/Functions:
{chr(10).join(spell_docs)[:8000]} 

Summary:
"""
        try:
            return self.embedding_provider.generate_content(prompt)
        except Exception as e:
            logger.error(f"Failed to generate summary: {e}")
            return f"Grimorium {grimorium_name} containing various magical tools."

    def _compute_grimorium_hash(self, folder_path: Path) -> str:
        """Computes a hash of all python files in the folder to detect changes."""
        hasher = hashlib.md5()
        # Sort files to ensure deterministic hash
        py_files = sorted(list(folder_path.rglob("*.py")))
        for py_file in py_files:
            if py_file.name.startswith((".", "_")):
                continue
            try:
                # We hash the content to detect functional changes
                content = py_file.read_bytes()
                hasher.update(content)
            except Exception:
                continue
        return hasher.hexdigest()

    async def close(self) -> None:
        """Cleanup synchronizer resources."""
        logger.debug("Closing SpellSync...")
        if hasattr(self.vector_store, "close"):
            await self.vector_store.close()
        if hasattr(self.embedding_provider, "close"):
            await self.embedding_provider.close()

    def sync_spells(self):
        """Synchronizes spells to the unified database, separated by collections."""
        logger.info("Starting unified spell synchronization...")

        all_spells = self.registry
        if not all_spells:
            return

        # Group spells by book (collection)
        book_buckets = {}
        for spell_name, spell_func in all_spells.items():
            # Determine collection from module name
            module_name = getattr(spell_func, "__module__", "")

            # Default to 'default' if unknown
            book_name = "default_grimorium"

            # Extract from module path: grimorium.discovered_spells.<book_name>.<file>
            if module_name and module_name.startswith("magetools.discovered_spells."):
                parts = module_name.split(".")
                if len(parts) >= 3:
                    book_name = parts[2]

            # Allow manual override
            if hasattr(spell_func, COLLECTION_ATTR_NAME):
                book_name = getattr(spell_func, COLLECTION_ATTR_NAME)

            if book_name not in book_buckets:
                book_buckets[book_name] = []
            book_buckets[book_name].append((spell_name, spell_func))

        # Process each bucket into its own collection
        for book_name, spells in book_buckets.items():
            logger.info(f"Syncing collection: {book_name}")

            try:
                collection = self.get_grimorium_collection(book_name)

                # Fetch existing metadata for diffing (same logic as before)
                existing_hashes = {}
                try:
                    result = collection.get(include=["metadatas"])
                    if result and result["ids"]:
                        for i, spell_id in enumerate(result["ids"]):
                            if result["metadatas"] and len(result["metadatas"]) > i:
                                meta = result["metadatas"][i]
                                if meta and "hash" in meta:
                                    existing_hashes[spell_id] = meta["hash"]
                except Exception:
                    existing_hashes = {}

                ids = []
                documents = []
                metadatas = []
                skipped = 0

                for spell_name, spell_func in spells:
                    docstring = spell_func.__doc__ or ""
                    current_hash = hashlib.md5(docstring.encode("utf-8")).hexdigest()

                    if (
                        spell_name in existing_hashes
                        and existing_hashes[spell_name] == current_hash
                    ):
                        skipped += 1
                        continue

                    ids.append(spell_name)
                    documents.append(docstring)
                    metadatas.append({"name": spell_name, "hash": current_hash})

                if ids:
                    collection.upsert(ids=ids, documents=documents, metadatas=metadatas)
                    logger.info(
                        f"Upserted {len(ids)} spells to collection '{book_name}'"
                    )

                if skipped > 0:
                    logger.info(f"Skipped {skipped} up-to-date spells in '{book_name}'")

            except Exception as e:
                logger.error(f"Failed to sync collection '{book_name}': {e}")

        logger.info("Unified spell synchronization complete.")


def discover_and_load_spells(
    root_path: Path | None = None,
    registry: dict[str, Any] | None = None,
    strict_mode: bool = True,
):
    """Dynamically discover and load spells from the .magetools directory.

    Args:
        root_path: Optional path to search for spells.
        registry: Optional dict to populate with discovered spells.
        strict_mode: If True (default), only load spells from folders that have
                    a manifest.json file. This is a security feature to prevent
                    accidental execution of arbitrary code.
    """

    if root_path:
        search_path = root_path
    else:
        # Fallback to CWD-based default using config
        config = get_config()
        search_path = config.magetools_root

    logger.info(f"Scanning for spells (strict_mode={strict_mode}): {search_path}")

    if not search_path.exists():
        logger.warning(
            f"Magetools directory not found at {search_path}. "
            "Please create a '.magetools' folder to store your spells."
        )
        return

    # Walk through all subdirectories in .magetools
    # Each subdirectory is a collection (Grimorium)
    for collection_dir in search_path.iterdir():
        if not collection_dir.is_dir() or collection_dir.name.startswith((".", "_")):
            continue

        collection_name = collection_dir.name

        # Load manifest for this collection (if exists)
        manifest = _load_manifest(collection_dir)

        # STRICT MODE: Require manifest.json for security
        if strict_mode and not manifest:
            py_files = list(collection_dir.rglob("*.py"))
            public_py_files = [f for f in py_files if not f.name.startswith((".", "_"))]
            if public_py_files:
                logger.warning(
                    f"Skipping collection '{collection_name}': No manifest.json found (strict_mode=True). "
                    f"Found {len(public_py_files)} Python file(s) that will NOT be loaded. "
                    f"Add a manifest.json to enable this collection."
                )
            continue

        # Check if collection is disabled
        if manifest and not manifest.get("enabled", True):
            logger.info(f"Skipping disabled collection: {collection_name}")
            continue

        if manifest:
            logger.info(f"Loaded manifest for collection: {collection_name}")

        logger.info(f"Found collection directory: {collection_name}")

        for py_file in collection_dir.rglob("*.py"):
            if py_file.name.startswith((".", "_")):
                continue

            # Module name includes collection to avoid collisions
            # e.g. grimorium.discovered_spells.arcane.fireball
            module_name = (
                f"magetools.discovered_spells.{collection_name}.{py_file.stem}"
            )

            try:
                # Pre-check syntax to avoid crashing on import
                with open(py_file, encoding="utf-8") as f:
                    source = f.read()
                ast.parse(source)
            except Exception as e:
                logger.warning(f"Skipping {py_file} due to syntax/read error: {e}")
                continue

            try:
                spec = importlib.util.spec_from_file_location(module_name, py_file)
                if spec and spec.loader:
                    module = importlib.util.module_from_spec(spec)
                    # Tag the module with its collection for SpellSync to use
                    setattr(module, COLLECTION_ATTR_NAME, collection_name)

                    sys.modules[module_name] = module
                    spec.loader.exec_module(module)

                    # SCAN FOR SPELLS
                    count = 0
                    for name, obj in inspect.getmembers(module):
                        if getattr(obj, "_grimorium_spell", False) is True:
                            spell_name = obj.__name__

                            # Check manifest whitelist/blacklist
                            if not _is_spell_allowed(spell_name, manifest):
                                logger.debug(
                                    f"Spell '{spell_name}' blocked by manifest in {collection_name}"
                                )
                                continue

                            # Register the spell
                            key = f"{collection_name}.{spell_name}"

                            if registry is not None:
                                registry[key] = obj
                                count += 1

                    if count > 0:
                        logger.info(
                            f"Loaded {count} spells from {py_file} into collection '{collection_name}'"
                        )
            except Exception as e:
                logger.warning(f"Warning: Failed to load spells from {py_file}: {e}")


def _load_manifest(collection_dir: Path) -> dict | None:
    """Load manifest.json from a collection directory.

    The manifest can contain:
    - "whitelist": List of spell names to explicitly allow (if set, only these are loaded)
    - "blacklist": List of spell names to block (applied after whitelist)
    - "enabled": Boolean to enable/disable entire collection (default: true)
    - "version": Manifest schema version (for future compatibility)

    Returns:
        Parsed manifest dict or None if not found.
    """
    manifest_path = collection_dir / "manifest.json"
    if not manifest_path.exists():
        return None

    try:
        with open(manifest_path, encoding="utf-8") as f:
            manifest = json.load(f)

        # Validate basic schema
        if not isinstance(manifest, dict):
            logger.warning(f"Invalid manifest in {collection_dir}: expected dict")
            return None

        return manifest
    except json.JSONDecodeError as e:
        logger.warning(f"Invalid JSON in manifest at {manifest_path}: {e}")
        return None
    except Exception as e:
        logger.warning(f"Failed to load manifest from {manifest_path}: {e}")
        return None


def _is_spell_allowed(spell_name: str, manifest: dict | None) -> bool:
    """Check if a spell is allowed by the manifest rules.

    Rules:
    1. If no manifest, all spells allowed
    2. If manifest.enabled is False, no spells allowed
    3. If whitelist exists, only whitelisted spells allowed
    4. If blacklist exists, blacklisted spells blocked
    """
    if manifest is None:
        return True

    # Check if collection is enabled
    if not manifest.get("enabled", True):
        return False

    whitelist = manifest.get("whitelist")
    blacklist = manifest.get("blacklist", [])

    # Whitelist takes precedence
    if whitelist is not None:
        if spell_name not in whitelist:
            return False

    # Check blacklist
    if spell_name in blacklist:
        return False

    return True
