"""
Management command to create Neo4j indexes for Mnemosyne content graph.

Creates:
- Vector indexes for Chunk, Concept, and ImageEmbedding. Dimensions are read
  from the system embedding model — NOT a settings default. If no model is
  configured and no ``--dimensions`` override is supplied, the command
  fails: a wrong dimension is worse than a clear error.
- Full-text indexes for text search on Chunk.text_preview, Concept.name,
  Item.title, Library.name.
- Constraint indexes enforced by neomodel (unique properties).

When run via the `init` sidecar, a non-zero exit here blocks ``app``/``mcp``/
``worker`` from starting. That is deliberate: silent zero-result searches
are the failure mode we are trying to prevent.
"""

import logging
import re
import sys

from django.core.management.base import BaseCommand, CommandError
from neomodel import db

logger = logging.getLogger(__name__)

# Full-text index definitions: (index_name, label, properties)
FULLTEXT_INDEXES = [
    ("chunk_text_fulltext", "Chunk", ["text_preview"]),
    ("concept_name_fulltext", "Concept", ["name"]),
    ("item_title_fulltext", "Item", ["title"]),
    ("library_name_fulltext", "Library", ["name"]),
]


def _get_vector_dimensions(override: int = 0):
    """
    Resolve the dimension count to build vector indexes at.

    Precedence (top wins):
      1. ``--dimensions`` CLI override (positive int)
      2. System embedding model's ``vector_dimensions`` field

    If neither is available, returns ``(None, reason)`` and the caller must
    abort. There is deliberately no hardcoded fallback — an index built at
    the wrong dimension silently breaks search forever.

    :param override: Value from the ``--dimensions`` CLI flag (0 if unset).
    :returns: Tuple of ``(dimensions, source_description)``. ``dimensions``
              is ``None`` when the value cannot be resolved.
    """
    if override > 0:
        return override, f"CLI override (--dimensions={override})"

    try:
        from llm_manager.models import LLMModel

        model = LLMModel.get_system_embedding_model()
    except Exception as exc:  # pragma: no cover - DB unreachable path
        return None, f"LLMModel lookup failed: {exc}"

    if not model:
        return None, "no system embedding model configured"
    if not model.vector_dimensions:
        return (
            None,
            f"system embedding model '{model.api.name}: {model.name}' has "
            "no vector_dimensions set",
        )

    return model.vector_dimensions, f"{model.api.name}: {model.name}"


def _existing_vector_index_dimensions(name: str):
    """
    Return the configured dimension count of an existing vector index.

    Neo4j's ``SHOW INDEXES`` returns ``options`` as a map whose
    ``indexConfig`` entry holds ``vector.dimensions``. Returns ``None`` if
    the index doesn't exist, isn't a vector index, or the dimension cannot
    be parsed (e.g. Neo4j version differences).
    """
    try:
        results, _ = db.cypher_query(
            "SHOW INDEXES YIELD name, type, options "
            "WHERE name = $name RETURN type, options",
            {"name": name},
        )
    except Exception:
        return None

    if not results:
        return None

    idx_type, options = results[0]
    if (idx_type or "").upper() != "VECTOR":
        return None

    # ``options`` comes back as a dict in the Python driver.
    config = (options or {}).get("indexConfig") or {}
    dims = config.get("vector.dimensions")
    if dims is None:
        return None
    try:
        return int(dims)
    except (TypeError, ValueError):
        # Neo4j sometimes returns the value as a string like "4096".
        m = re.search(r"\d+", str(dims))
        return int(m.group(0)) if m else None


class Command(BaseCommand):
    help = (
        "Create Neo4j vector, full-text, and constraint indexes for the "
        "Mnemosyne content graph. Vector dimensions are read from the "
        "system embedding model; the command fails if the model is not "
        "configured (pass --dimensions only for explicit overrides)."
    )

    def add_arguments(self, parser):
        parser.add_argument(
            "--drop",
            action="store_true",
            help="Drop existing managed indexes before recreating them.",
        )
        parser.add_argument(
            "--dimensions",
            type=int,
            default=0,
            help=(
                "Override vector dimensions (normally read from the system "
                "embedding model). Use with care — an incorrect value "
                "silently breaks search."
            ),
        )

    def handle(self, *args, **options):
        drop = options["drop"]
        override_dims = options["dimensions"]

        dimensions, source = _get_vector_dimensions(override_dims)

        if dimensions is None:
            # Fail loudly. Returning success while skipping vector-index
            # creation is exactly how `app`/`mcp`/`worker` end up running
            # against an un-indexed Neo4j and serving empty search results.
            raise CommandError(
                "Cannot create vector indexes: "
                f"{source}. Configure a system embedding model with "
                "vector_dimensions set, or pass --dimensions N explicitly."
            )

        self.stdout.write(
            self.style.HTTP_INFO(
                f"Vector dimensions: {dimensions} (source: {source})"
            )
        )

        # Vector index definitions (dimensions resolved above)
        vector_indexes = [
            ("chunk_embedding_index", "Chunk", "embedding", dimensions, "cosine"),
            ("concept_embedding_index", "Concept", "embedding", dimensions, "cosine"),
            ("image_embedding_index", "ImageEmbedding", "embedding", dimensions, "cosine"),
        ]

        # Get existing indexes
        existing_indexes = self._get_existing_indexes()

        if drop:
            self._drop_indexes(existing_indexes, vector_indexes)
            existing_indexes = self._get_existing_indexes()

        dim_mismatch = False

        # Create vector indexes
        for name, label, prop, dims, similarity in vector_indexes:
            if name in existing_indexes:
                existing_dim = _existing_vector_index_dimensions(name)
                if existing_dim is not None and existing_dim != dims:
                    dim_mismatch = True
                    self.stderr.write(
                        self.style.ERROR(
                            f"Vector index '{name}' exists at {existing_dim} "
                            f"dimensions but the system model requires {dims}. "
                            "Re-run with --drop and re-embed all content."
                        )
                    )
                    continue
                self.stdout.write(
                    self.style.NOTICE(
                        f"Vector index '{name}' already exists "
                        f"({existing_dim or 'unknown'}d), skipping"
                    )
                )
                continue
            try:
                cypher = (
                    f"CREATE VECTOR INDEX {name} IF NOT EXISTS "
                    f"FOR (n:{label}) ON (n.{prop}) "
                    f"OPTIONS {{indexConfig: {{"
                    f"`vector.dimensions`: {dims}, "
                    f"`vector.similarity_function`: '{similarity}'"
                    f"}}}}"
                )
                db.cypher_query(cypher)
                self.stdout.write(
                    self.style.SUCCESS(
                        f"Created vector index: {name} ({label}.{prop}, {dims}d {similarity})"
                    )
                )
            except Exception as e:
                self.stderr.write(
                    self.style.ERROR(f"Failed to create vector index '{name}': {e}")
                )

        # Create full-text indexes
        for name, label, properties in FULLTEXT_INDEXES:
            if name in existing_indexes:
                self.stdout.write(
                    self.style.NOTICE(
                        f"Full-text index '{name}' already exists, skipping"
                    )
                )
                continue
            try:
                props_str = ", ".join(f"n.{p}" for p in properties)
                cypher = (
                    f"CREATE FULLTEXT INDEX {name} IF NOT EXISTS "
                    f"FOR (n:{label}) ON EACH [{props_str}]"
                )
                db.cypher_query(cypher)
                self.stdout.write(
                    self.style.SUCCESS(
                        f"Created full-text index: {name} ({label}: {', '.join(properties)})"
                    )
                )
            except Exception as e:
                self.stderr.write(
                    self.style.ERROR(f"Failed to create full-text index '{name}': {e}")
                )

        # Install neomodel constraints (unique indexes from model definitions)
        try:
            from neomodel import install_all_labels

            install_all_labels()
            self.stdout.write(
                self.style.SUCCESS("Installed neomodel constraint indexes")
            )
        except Exception as e:
            self.stderr.write(
                self.style.ERROR(f"Failed to install neomodel labels: {e}")
            )

        if dim_mismatch:
            # Exit non-zero so the `init` sidecar's compose dependency marks
            # the stack startup as failed. Admin must re-run with --drop and
            # re-embed content.
            self.stderr.write(
                self.style.ERROR(
                    "\nOne or more vector indexes exist at the wrong "
                    "dimension. Search will return empty or garbage results "
                    "until you run: setup_neo4j_indexes --drop "
                    "and re-embed all content."
                )
            )
            sys.exit(2)

        self.stdout.write(self.style.SUCCESS("\nNeo4j index setup complete."))

    def _get_existing_indexes(self):
        """Return set of existing index names."""
        try:
            results, _ = db.cypher_query("SHOW INDEXES YIELD name RETURN name")
            return {row[0] for row in results}
        except Exception:
            return set()

    def _drop_indexes(self, existing_indexes, vector_indexes):
        """Drop all Mnemosyne-managed indexes."""
        managed_names = {name for name, *_ in vector_indexes} | {
            name for name, *_ in FULLTEXT_INDEXES
        }
        for name in managed_names & existing_indexes:
            try:
                db.cypher_query(f"DROP INDEX {name} IF EXISTS")
                self.stdout.write(self.style.WARNING(f"Dropped index: {name}"))
            except Exception as e:
                self.stderr.write(
                    self.style.ERROR(f"Failed to drop index '{name}': {e}")
                )
