"""Dynamic index registry and value conversion for plone.pgcatalog.

Provides an ``IndexRegistry`` that dynamically discovers indexes from the
ZCatalog's registered index objects (populated via GenericSetup catalog.xml
imports).  Each index is mapped to an ``IndexType`` for query translation
and an ``idx_key`` for JSONB storage, plus ``source_attrs`` for value
extraction (from ``indexed_attr`` in catalog.xml).

Design: idx keys use the exact ZCatalog index name — no translation.
This keeps query mapping trivial and brain attribute access natural.
"""

from datetime import date
from datetime import datetime
from datetime import UTC
from enum import Enum

import logging
import re


__all__ = [
    "IndexRegistry",
    "IndexType",
    "compute_path_info",
    "convert_value",
    "ensure_date_param",
    "get_registry",
    "language_to_regconfig",
    "validate_identifier",
]


log = logging.getLogger(__name__)

# Safe SQL identifier pattern: letters, digits, underscores only.
# Prevents SQL injection when idx_key values are interpolated into queries.
_SAFE_IDENTIFIER_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")


def validate_identifier(name):
    """Validate that a name is safe for use as a SQL identifier.

    Raises ValueError if the name contains characters that could
    enable SQL injection when interpolated into query strings.
    """
    if not _SAFE_IDENTIFIER_RE.match(name):
        raise ValueError(
            f"Invalid identifier {name!r}: must match [a-zA-Z_][a-zA-Z0-9_]*"
        )


class IndexType(Enum):
    """ZCatalog index types we handle natively."""

    FIELD = "FieldIndex"
    KEYWORD = "KeywordIndex"
    DATE = "DateIndex"
    BOOLEAN = "BooleanIndex"
    DATE_RANGE = "DateRangeIndex"
    UUID = "UUIDIndex"
    TEXT = "ZCTextIndex"
    PATH = "ExtendedPathIndex"
    GOPIP = "GopipIndex"


# --------------------------------------------------------------------------
# Meta-type → IndexType mapping
# --------------------------------------------------------------------------

META_TYPE_MAP = {
    "FieldIndex": IndexType.FIELD,
    "KeywordIndex": IndexType.KEYWORD,
    "DateIndex": IndexType.DATE,
    "DateRecurringIndex": IndexType.DATE,  # query via IPGIndexTranslator (rrule)
    "BooleanIndex": IndexType.BOOLEAN,
    "DateRangeIndex": IndexType.DATE_RANGE,
    "UUIDIndex": IndexType.UUID,
    "ZCTextIndex": IndexType.TEXT,
    "ExtendedPathIndex": IndexType.PATH,
    "PathIndex": IndexType.PATH,
    "GopipIndex": IndexType.GOPIP,
}

# Indexes with special PG handling (idx_key=None): dedicated columns or
# composite logic that can't be expressed as a simple JSONB key.
SPECIAL_INDEXES = frozenset({"SearchableText", "effectiveRange", "path"})


# --------------------------------------------------------------------------
# Dynamic index registry
# --------------------------------------------------------------------------


class IndexRegistry:
    """Dynamic index registry populated from ZCatalog's registered indexes.

    Starts empty — populated via ``sync_from_catalog()`` which reads
    ``catalog._catalog.indexes`` and ``catalog._catalog.schema``.

    Each index entry is a tuple ``(IndexType, idx_key, source_attrs)`` where:
    - ``idx_key``: JSONB key in the ``idx`` column (usually the index name),
      or ``None`` for special indexes (SearchableText, effectiveRange, path).
    - ``source_attrs``: list of object attribute names to extract via
      ``getattr(wrapper, attr)``, from ``getIndexSourceNames()``.
    """

    def __init__(self):
        self._indexes = {}
        self._metadata = set()

    def sync_from_catalog(self, catalog):
        """Populate registry from a ZCatalog tool's internal catalog.

        Reads ``catalog._catalog.indexes`` for queryable indexes and
        ``catalog._catalog.schema`` for metadata columns.
        """
        try:
            zcatalog_indexes = catalog._catalog.indexes
        except AttributeError:
            return

        for name, index_obj in zcatalog_indexes.items():
            if name in self._indexes:
                continue  # already registered

            meta_type = getattr(index_obj, "meta_type", None)
            if meta_type is None:
                continue

            idx_type = META_TYPE_MAP.get(meta_type)
            if idx_type is None:
                log.debug(
                    "Unknown index meta_type %r for %r — skipping", meta_type, name
                )
                continue

            # Validate index name as safe SQL identifier
            if name not in SPECIAL_INDEXES:
                try:
                    validate_identifier(name)
                except ValueError:
                    log.warning(
                        "Skipping index %r: name is not a safe SQL identifier", name
                    )
                    continue

            # Read source attributes from index object
            source_attrs = None
            if hasattr(index_obj, "getIndexSourceNames"):
                try:
                    source_attrs = list(index_obj.getIndexSourceNames())
                except Exception:
                    pass
            if not source_attrs:
                source_attrs = [name]

            # Special indexes get idx_key=None
            idx_key = None if name in SPECIAL_INDEXES else name

            self._indexes[name] = (idx_type, idx_key, source_attrs)

        # Metadata columns from catalog schema
        try:
            schema = catalog._catalog.schema
            for col_name in schema:
                self._metadata.add(col_name)
        except AttributeError:
            pass

    def register(self, name, idx_type, idx_key, source_attrs=None):
        """Manually register an index.

        Args:
            name: index name (query key)
            idx_type: IndexType enum value
            idx_key: JSONB key (usually same as name)
            source_attrs: list of attribute names for extraction
                          (defaults to [idx_key])

        Raises:
            ValueError: if idx_key is not a safe SQL identifier
        """
        if idx_key is not None:
            validate_identifier(idx_key)
        if source_attrs is None:
            source_attrs = [idx_key] if idx_key is not None else [name]
        self._indexes[name] = (idx_type, idx_key, source_attrs)

    def add_metadata(self, name):
        """Register a metadata-only column name."""
        self._metadata.add(name)

    @property
    def metadata(self):
        """Set of metadata column names."""
        return self._metadata

    # -- dict-like API ---

    def __contains__(self, name):
        return name in self._indexes

    def __getitem__(self, name):
        return self._indexes[name]

    def __len__(self):
        return len(self._indexes)

    def get(self, name, default=None):
        return self._indexes.get(name, default)

    def items(self):
        return self._indexes.items()

    def keys(self):
        return self._indexes.keys()

    def values(self):
        return self._indexes.values()


# Module-level singleton
_registry = IndexRegistry()


def get_registry():
    """Return the module-level IndexRegistry singleton."""
    return _registry


# --------------------------------------------------------------------------
# Value conversion: Python/Zope types → JSON-safe values
# --------------------------------------------------------------------------


def convert_value(value):
    """Convert a Python/Zope value to a JSON-safe value for idx JSONB.

    Handles: DateTime, datetime, date, bool, int, float, str, list, tuple,
    None, and falls back to str() for unknown types.
    """
    if value is None:
        return None

    # Bool must come before int (bool is a subclass of int)
    if isinstance(value, bool):
        return value

    if isinstance(value, (int, float, str)):
        return value

    # Zope DateTime → ISO 8601
    if _is_zope_datetime(value):
        return _convert_zope_datetime(value)

    # Python datetime/date → ISO 8601
    if isinstance(value, datetime):
        return value.isoformat()

    if isinstance(value, date):
        return value.isoformat()

    # Sequences → lists
    if isinstance(value, (list, tuple, set, frozenset)):
        return [convert_value(v) for v in value]

    # Dict → recurse
    if isinstance(value, dict):
        return {k: convert_value(v) for k, v in value.items()}

    # Fallback: string representation
    return str(value)


def _is_zope_datetime(value):
    """Check if value is a Zope DateTime object (duck-typing)."""
    return hasattr(value, "ISO8601") and callable(value.ISO8601)


def _convert_zope_datetime(dt):
    """Convert a Zope DateTime to ISO 8601 string."""
    return dt.ISO8601()


def ensure_date_param(value):
    """Convert a date-like value to something psycopg can bind as timestamptz.

    Handles Python datetime, date, Zope DateTime (duck-typed via
    asdatetime/ISO8601), Unix epoch floats/ints (from time.time()),
    and falls back to str().
    """
    if isinstance(value, datetime):
        return value
    if isinstance(value, date):
        return datetime(value.year, value.month, value.day)
    # Zope DateTime (duck-typed)
    if hasattr(value, "asdatetime"):
        return value.asdatetime()
    if hasattr(value, "ISO8601"):
        return value.ISO8601()
    # Unix epoch timestamp (e.g. from time.time())
    if isinstance(value, (int, float)):
        return datetime.fromtimestamp(value, tz=UTC)
    return str(value)


# --------------------------------------------------------------------------
# Language → PG text search configuration mapping
# --------------------------------------------------------------------------

# Plone language code (ISO 639-1) → PostgreSQL regconfig name.
# Mirrors the SQL function pgcatalog_lang_to_regconfig() in schema.py.
_LANG_TO_REGCONFIG = {
    "ar": "arabic",
    "hy": "armenian",
    "eu": "basque",
    "ca": "catalan",
    "da": "danish",
    "nl": "dutch",
    "en": "english",
    "et": "estonian",
    "fi": "finnish",
    "fr": "french",
    "de": "german",
    "el": "greek",
    "hi": "hindi",
    "hu": "hungarian",
    "id": "indonesian",
    "ga": "irish",
    "it": "italian",
    "lt": "lithuanian",
    "ne": "nepali",
    "nb": "norwegian",
    "nn": "norwegian",
    "no": "norwegian",
    "pt": "portuguese",
    "ro": "romanian",
    "ru": "russian",
    "sr": "serbian",
    "es": "spanish",
    "sv": "swedish",
    "ta": "tamil",
    "tr": "turkish",
    "yi": "yiddish",
}


def language_to_regconfig(lang):
    """Map a Plone language code to a PG text search configuration name.

    Args:
        lang: Plone language code (e.g. "de", "en-us") or None/""

    Returns:
        PG regconfig name (e.g. "german", "english") or "simple"
    """
    if not lang:
        return "simple"
    base = lang.lower().split("-")[0].split("_")[0]
    return _LANG_TO_REGCONFIG.get(base, "simple")


# --------------------------------------------------------------------------
# Path utilities
# --------------------------------------------------------------------------


def compute_path_info(path):
    """Compute parent_path and path_depth from a path string.

    Args:
        path: physical path like "/plone/folder/doc"

    Returns:
        (parent_path, path_depth) tuple.
        parent_path: "/plone/folder" (parent's path)
        path_depth: 3 (number of non-empty components)
    """
    parts = [p for p in path.split("/") if p]
    depth = len(parts)

    parent = "/" if depth <= 1 else "/" + "/".join(parts[:-1])

    return parent, depth
