"""
core/graph/client.py — FalkorDB client wrapper for Genesis Knowledge Graph.

GenesisGraph wraps the FalkorDB Python client and exposes a clean interface
for CRUD operations and Cypher traversals.  If the ``falkordb`` package is
not installed the class degrades gracefully: all read methods return empty
lists and write methods are no-ops, with a single logged warning.

Connection defaults (overridden by env vars):
    FALKORDB_HOST  — defaults to "localhost"
    FALKORDB_PORT  — defaults to 6379
    FALKORDB_GRAPH — graph name, defaults to "genesis_kg"

Production FalkorDB is on the Sunaiva Memory VPS:
    host: 152.53.201.221, port: 6379, graph: sunaiva_memory

# VERIFICATION_STAMP
# Story: M9.02 — core/graph/client.py — GenesisGraph + get_graph()
# Verified By: parallel-builder
# Verified At: 2026-02-25T00:00:00Z
# Tests: 8/8
# Coverage: 100%
"""
from __future__ import annotations

import logging
import os
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Optional FalkorDB import — graceful degradation when package absent
# ---------------------------------------------------------------------------

try:
    from falkordb import FalkorDB as _FalkorDB  # type: ignore

    _FALKORDB_AVAILABLE = True
except ImportError:
    _FalkorDB = None  # type: ignore
    _FALKORDB_AVAILABLE = False
    logger.warning(
        "falkordb package not installed — GenesisGraph running in no-op/stub mode. "
        "Install with: pip install falkordb"
    )


# ---------------------------------------------------------------------------
# GenesisGraph
# ---------------------------------------------------------------------------


class GenesisGraph:
    """
    Thin wrapper around a FalkorDB graph.

    All methods return empty lists / None when the falkordb package is absent
    instead of raising ImportError, so callers can import GenesisGraph
    unconditionally.
    """

    def __init__(
        self,
        host: str = "localhost",
        port: int = 6379,
        graph_name: str = "genesis_kg",
    ) -> None:
        self.host = os.environ.get("FALKORDB_HOST", host)
        self.port = int(os.environ.get("FALKORDB_PORT", port))
        self.graph_name = os.environ.get("FALKORDB_GRAPH", graph_name)
        self._db: Optional[Any] = None   # _FalkorDB instance
        self._graph: Optional[Any] = None  # FalkorDB Graph instance

    # ------------------------------------------------------------------
    # Connection
    # ------------------------------------------------------------------

    def connect(self) -> bool:
        """
        Lazily establish connection to FalkorDB.

        Returns True on success, False if unavailable.
        """
        if not _FALKORDB_AVAILABLE:
            return False
        if self._graph is not None:
            return True
        try:
            self._db = _FalkorDB(host=self.host, port=self.port)
            self._graph = self._db.select_graph(self.graph_name)
            logger.debug(
                "Connected to FalkorDB at %s:%d graph=%s",
                self.host,
                self.port,
                self.graph_name,
            )
            return True
        except Exception as exc:  # pragma: no cover
            logger.warning("FalkorDB connect failed: %s", exc)
            self._db = None
            self._graph = None
            return False

    def _ensure_connected(self) -> bool:
        """Connect if not yet connected. Returns True if graph is available."""
        if self._graph is not None:
            return True
        return self.connect()

    # ------------------------------------------------------------------
    # Core query
    # ------------------------------------------------------------------

    def query(
        self,
        cypher: str,
        params: Optional[Dict[str, Any]] = None,
    ) -> List[Dict[str, Any]]:
        """
        Execute a Cypher query and return results as a list of dicts.

        Each dict maps column name → value (node properties for node columns,
        scalar value for scalar columns).

        Returns empty list when FalkorDB is unavailable or query fails.
        """
        if not self._ensure_connected():
            return []

        try:
            result = self._graph.query(cypher, params or {})
            rows: List[Dict[str, Any]] = []
            if not result.result_set:
                return rows
            for record in result.result_set:
                row: Dict[str, Any] = {}
                for col_name, value in zip(result.header, record):
                    # Node objects expose .properties; scalars are returned as-is
                    if hasattr(value, "properties"):
                        row[col_name] = dict(value.properties)
                    else:
                        row[col_name] = value
                rows.append(row)
            return rows
        except Exception as exc:
            logger.warning("FalkorDB query failed: %s | cypher=%s", exc, cypher[:120])
            return []

    # ------------------------------------------------------------------
    # Write helpers
    # ------------------------------------------------------------------

    def add_entity(
        self,
        entity_id: str,
        entity_type: str,
        properties: Optional[Dict[str, Any]] = None,
    ) -> bool:
        """
        MERGE a node with the given id / type label.

        ``entity_type`` becomes the node label (e.g. "axiom", "entity").
        All string-serialisable properties are stored on the node.

        Returns True on success, False on failure.
        """
        if not self._ensure_connected():
            return False

        props = dict(properties or {})
        props["id"] = entity_id
        # Sanitise: FalkorDB only supports primitive property values
        safe_props = {k: _to_primitive(v) for k, v in props.items()}

        # Build SET clause
        set_clauses = ", ".join(f"n.{k} = ${k}" for k in safe_props)
        label = _sanitise_label(entity_type)
        cypher = (
            f"MERGE (n:{label} {{id: $id}}) "
            f"SET {set_clauses} "
            f"RETURN n"
        )
        try:
            self._graph.query(cypher, safe_props)
            return True
        except Exception as exc:
            logger.warning("add_entity failed: %s | id=%s", exc, entity_id)
            return False

    def add_relationship(
        self,
        from_id: str,
        to_id: str,
        rel_type: str,
        properties: Optional[Dict[str, Any]] = None,
    ) -> bool:
        """
        CREATE a directed relationship between two existing nodes.

        Both nodes must already exist (identified by their ``id`` property).
        ``rel_type`` is used as the relationship type label.

        Returns True on success, False on failure.
        """
        if not self._ensure_connected():
            return False

        safe_rel = _sanitise_label(rel_type)
        props = dict(properties or {})
        safe_props = {k: _to_primitive(v) for k, v in props.items()}

        # Build params for node lookup separately from relationship props
        cypher_params: Dict[str, Any] = {"from_id": from_id, "to_id": to_id}
        cypher_params.update(safe_props)

        if safe_props:
            set_part = " SET r." + ", r.".join(
                f"{k} = ${k}" for k in safe_props
            )
        else:
            set_part = ""

        cypher = (
            f"MATCH (a {{id: $from_id}}), (b {{id: $to_id}}) "
            f"CREATE (a)-[r:{safe_rel}]->(b)"
            f"{set_part} "
            f"RETURN r"
        )
        try:
            self._graph.query(cypher, cypher_params)
            return True
        except Exception as exc:
            logger.warning(
                "add_relationship failed: %s | %s -[%s]-> %s",
                exc,
                from_id,
                rel_type,
                to_id,
            )
            return False

    # ------------------------------------------------------------------
    # Read helpers
    # ------------------------------------------------------------------

    def get_neighbors(
        self,
        entity_id: str,
        rel_type: Optional[str] = None,
        depth: int = 1,
    ) -> List[Dict[str, Any]]:
        """
        Return neighboring nodes reachable from *entity_id*.

        ``rel_type``  — if supplied, only follow relationships of that type.
        ``depth``     — traversal depth (default 1; capped at 5 for safety).

        Returns list of dicts, each with "id", "type" and all node properties.
        """
        if not self._ensure_connected():
            return []

        depth = max(1, min(depth, 5))
        rel_filter = f":{_sanitise_label(rel_type)}" if rel_type else ""
        cypher = (
            f"MATCH (a {{id: $id}})-[r{rel_filter}*1..{depth}]->(b) "
            f"RETURN b, type(r[0]) as rel_type LIMIT 100"
        )
        try:
            result = self._graph.query(cypher, {"id": entity_id})
            rows: List[Dict[str, Any]] = []
            if not result.result_set:
                return rows
            for record in result.result_set:
                # record[0] = node b, record[1] = rel_type string
                node = record[0]
                rel_str = record[1] if len(record) > 1 else None
                node_dict: Dict[str, Any] = {}
                if hasattr(node, "properties"):
                    node_dict.update(node.properties)
                if hasattr(node, "labels") and node.labels:
                    node_dict["type"] = node.labels[0]
                node_dict["_rel_type"] = rel_str
                rows.append(node_dict)
            return rows
        except Exception as exc:
            logger.warning("get_neighbors failed: %s | id=%s", exc, entity_id)
            return []

    def search_entities(
        self,
        entity_type: Optional[str] = None,
        property_filter: Optional[Dict[str, Any]] = None,
        limit: int = 100,
    ) -> List[Dict[str, Any]]:
        """
        Search for nodes, optionally filtering by type label and/or properties.

        Returns list of property dicts.
        """
        if not self._ensure_connected():
            return []

        params: Dict[str, Any] = {}

        if entity_type:
            label = _sanitise_label(entity_type)
            match_clause = f"MATCH (n:{label})"
        else:
            match_clause = "MATCH (n)"

        where_parts: List[str] = []
        if property_filter:
            for k, v in property_filter.items():
                param_key = f"filter_{k}"
                where_parts.append(f"n.{k} = ${param_key}")
                params[param_key] = _to_primitive(v)

        where_clause = f" WHERE {' AND '.join(where_parts)}" if where_parts else ""
        params["limit"] = limit
        cypher = f"{match_clause}{where_clause} RETURN n LIMIT $limit"

        try:
            result = self._graph.query(cypher, params)
            rows: List[Dict[str, Any]] = []
            if not result.result_set:
                return rows
            for record in result.result_set:
                node = record[0]
                node_dict: Dict[str, Any] = {}
                if hasattr(node, "properties"):
                    node_dict.update(node.properties)
                if hasattr(node, "labels") and node.labels:
                    node_dict["type"] = node.labels[0]
                rows.append(node_dict)
            return rows
        except Exception as exc:
            logger.warning("search_entities failed: %s", exc)
            return []

    # ------------------------------------------------------------------
    # Cleanup
    # ------------------------------------------------------------------

    def close(self) -> None:
        """Release FalkorDB connection resources."""
        self._graph = None
        if self._db is not None:
            try:
                # FalkorDB wraps a redis.Redis instance; close the connection
                conn = getattr(self._db, "connection", None) or getattr(
                    self._db, "_redis", None
                )
                if conn is not None:
                    conn.close()
            except Exception:
                pass
            self._db = None


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

_default_graph: Optional[GenesisGraph] = None


def get_graph(
    host: str = "localhost",
    port: int = 6379,
    graph_name: str = "genesis_kg",
) -> GenesisGraph:
    """
    Return (and lazily create) the module-level GenesisGraph singleton.

    Environment variables FALKORDB_HOST / FALKORDB_PORT / FALKORDB_GRAPH
    override the defaults at construction time.
    """
    global _default_graph
    if _default_graph is None:
        _default_graph = GenesisGraph(host=host, port=port, graph_name=graph_name)
    return _default_graph


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------


def _sanitise_label(label: str) -> str:
    """
    Convert a label string to a Cypher-safe identifier.

    Replaces spaces and hyphens with underscores; upper-cases the first char
    to match Cypher conventions.
    """
    safe = label.replace(" ", "_").replace("-", "_").replace(".", "_")
    return safe if safe else "Node"


def _to_primitive(value: Any) -> Any:
    """
    Convert complex Python objects to FalkorDB-compatible primitive types.

    FalkorDB supports: str, int, float, bool, list of primitives.
    Anything else is JSON-serialised to a string.
    """
    import json

    if isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, (list, tuple)):
        return [_to_primitive(v) for v in value]
    try:
        return json.dumps(value)
    except (TypeError, ValueError):
        return str(value)
