"""T27 CatalogService - unified read/write API for RMI.

Per v4.0 §T27. The CatalogService is the ONLY sanctioned way to read or
write catalog data. Domain facades call this; they never touch stores directly.

Architecture:
  - Lazy store clients (init on first use, retry on failure)
  - Graceful degradation: unreachable stores return None/empty, not crash
  - Redis cache layer (always available since rmi-redis is up)
  - Cross-store ID conventions from app.catalog.models

Recipe coverage:
  Recipe 1: find_tokens_by_deployer_history   (Postgres + Neo4j)
  Recipe 2: find_similar_tokens                (Qdrant + Postgres)
  Recipe 3: get_token_risk                     (Redis + Postgres + Neo4j)
  Recipe 4: news_price_correlation             (Postgres, basic v1)
  Recipe 5: resolve_entity                     (Neo4j Cypher)
"""
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import time
from typing import Any

import httpx

from app.catalog.models import (
    COLLECTIONS,
    Chain,
    Token,
    Wallet,
    utcnow,
)
from app.rag.engine import ingest_document as rag_ingest_document
from app.rag.engine import three_pillar_search as rag_three_pillar_search

log = logging.getLogger(__name__)


# ── Connection config (from env, with sensible defaults to netcup IPs) ──
# These defaults point to the actual IPs on netcup rmi_network.
# Override via env vars in docker-compose.yml for portability.
DEFAULT_CONFIG: dict[str, Any] = {
    "redis": {
        "host": os.getenv("REDIS_HOST", "rmi-redis"),
        "port": int(os.getenv("REDIS_PORT", "6379")),
        "password": os.getenv("REDIS_PASSWORD", "RMI_PROD_REDIS_2026"),
        "db": int(os.getenv("REDIS_DB", "0")),
    },
    "postgres": {
        "host": os.getenv("POSTGRES_HOST", "rmi-postgres"),
        "port": int(os.getenv("POSTGRES_PORT", "5432")),
        "user": os.getenv("POSTGRES_USER", "rmi"),
        "password": os.getenv("POSTGRES_PASSWORD", ""),
        "database": os.getenv("POSTGRES_DB", "rmi"),
    },
    "neo4j": {
        "uri": os.getenv("NEO4J_URI", "bolt://rmi-neo4j:7687"),
        "user": os.getenv("NEO4J_USER", "neo4j"),
        "password": os.getenv("NEO4J_PASSWORD", ""),
    },
    "qdrant": {
        "url": os.getenv("QDRANT_URL", "http://rmi-qdrant:6333"),
        "api_key": os.getenv("QDRANT_API_KEY", ""),
    },
    "minio": {
        "endpoint": os.getenv("MINIO_ENDPOINT", "rmi-minio:9000"),
        "access_key": os.getenv("MINIO_ACCESS_KEY", ""),
        "secret_key": os.getenv("MINIO_SECRET_KEY", ""),
    },
}


class StoreHealth:
    """Tracks which stores are reachable. Updated on each probe."""

    def __init__(self) -> None:
        self.redis: bool = False
        self.postgres: bool = False
        self.neo4j: bool = False
        self.qdrant: bool = False
        self.minio: bool = False
        self.last_checked: float = 0.0


class CatalogService:
    """Unified read/write API for RMI data.

    Use `get_catalog()` to get the singleton instance. All methods are
    async and graceful-degrade - if a store is unreachable, the method
    returns None or an empty result with a logged warning.
    """

    def __init__(self, config: dict[str, Any] | None = None) -> None:
        self.config = config or DEFAULT_CONFIG
        self._health = StoreHealth()
        self._redis = None
        self._pg_pool: Any = None
        self._neo_driver: Any = None
        self._qdrant: Any = None  # httpx.AsyncClient
        self._init_lock = asyncio.Lock()

    # ── Store init (lazy) ────────────────────────────────────────────
    async def _init_stores(self) -> None:
        async with self._init_lock:
            if self._redis is not None:
                return  # already init
            # Redis (always try first - used for everything)
            try:
                import redis.asyncio as aioredis

                cfg = self.config["redis"]
                self._redis = aioredis.Redis(
                    host=cfg["host"],
                    port=cfg["port"],
                    password=cfg["password"],
                    db=cfg["db"],
                    decode_responses=True,
                )
                await self._redis.ping()
                self._health.redis = True
                log.info("catalog_redis_ok")
            except Exception as e:
                log.warning("catalog_redis_fail: %s", e)
                self._health.redis = False

            # Postgres
            try:
                import asyncpg

                cfg = self.config["postgres"]
                if cfg["password"]:
                    self._pg_pool = await asyncpg.create_pool(
                        host=cfg["host"],
                        port=cfg["port"],
                        user=cfg["user"],
                        password=cfg["password"],
                        database=cfg["database"],
                        min_size=1,
                        max_size=8,
                        command_timeout=10,
                    )
                    self._health.postgres = True
                    log.info("catalog_postgres_ok")
            except Exception as e:
                log.warning("catalog_postgres_fail: %s", e)
                self._health.postgres = False

            # Neo4j (NEO4J_AUTH=none means no password)
            try:
                from neo4j import GraphDatabase

                cfg = self.config["neo4j"]
                auth = (
                    (cfg["user"], cfg["password"]) if cfg["password"] else None
                )
                self._neo_driver = GraphDatabase.driver(cfg["uri"], auth=auth)
                with self._neo_driver.session() as s:
                    s.run("RETURN 1").consume()
                self._health.neo4j = True
                log.info("catalog_neo4j_ok")
            except Exception as e:
                log.warning("catalog_neo4j_fail: %s", e)
                self._health.neo4j = False

            # MinIO (HTTP health probe)
            try:
                import socket

                host, port = self.config["minio"]["endpoint"].rsplit(":", 1)
                s = socket.socket()
                s.settimeout(3)
                s.connect((host, int(port)))
                s.close()
                self._health.minio = True
                log.info("catalog_minio_ok")
            except Exception as e:
                log.warning("catalog_minio_fail: %s", e)
                self._health.minio = False

            # Qdrant (HTTP)
            try:
                cfg = self.config["qdrant"]
                headers = {"api-key": cfg["api_key"]} if cfg["api_key"] else {}
                self._qdrant = httpx.AsyncClient(
                    base_url=cfg["url"], headers=headers, timeout=5.0
                )
                r = await self._qdrant.get("/collections")
                if r.status_code == 200:
                    self._health.qdrant = True
                    log.info("catalog_qdrant_ok")
                else:
                    log.warning("catalog_qdrant_http_%d", r.status_code)
            except Exception as e:
                log.warning("catalog_qdrant_fail: %s", e)
                self._health.qdrant = False

            self._health.last_checked = time.time()

    # ── Health probe ────────────────────────────────────────────────
    async def probe_stores(self) -> dict[str, bool]:
        await self._init_stores()
        return {
            "redis": self._health.redis,
            "postgres": self._health.postgres,
            "neo4j": self._health.neo4j,
            "qdrant": self._health.qdrant,
            "minio": self._health.minio,
        }

    # ── Tokens (Postgres primary + Redis cache) ─────────────────────
    async def get_token(self, chain: Chain, address: str) -> Token | None:
        await self._init_stores()
        cache_key = f"catalog:token:{chain.value}:{address}"
        if self._health.redis:
            try:
                cached = await self._redis.get(cache_key)
                if cached:
                    return Token.model_validate_json(cached)
            except Exception as e:
                log.debug("token_cache_get_fail: %s", e)
        if not self._health.postgres:
            return None
        try:
            async with self._pg_pool.acquire() as conn:
                row = await conn.fetchrow(
                    "SELECT * FROM tokens WHERE chain=$1 AND address=$2",
                    chain.value, address,
                )
            if not row:
                return None
            token = Token(**dict(row))
            if self._health.redis:
                try:
                    await self._redis.setex(cache_key, 3600, token.model_dump_json())
                except Exception as e:
                    log.debug("token_cache_set_fail: %s", e)
            return token
        except Exception as e:
            log.warning("token_get_fail: %s", e)
            return None

    async def save_token(self, token: Token) -> bool:
        await self._init_stores()
        if not self._health.postgres:
            log.warning("token_save_no_postgres")
            return False
        try:
            async with self._pg_pool.acquire() as conn:
                await conn.execute(
                    """
                    INSERT INTO tokens (
                        token_id, chain, address, symbol, name, decimals,
                        deployer_wallet_id, deployed_at, initial_supply,
                        current_supply, is_honeypot, is_mintable, is_proxy,
                        tax_buy_bps, tax_sell_bps, risk_tier, risk_score,
                        risk_factors, rag_embedding_id
                    ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,
                              $14,$15,$16,$17,$18,$19)
                    ON CONFLICT (token_id) DO UPDATE SET
                        symbol=EXCLUDED.symbol,
                        name=EXCLUDED.name,
                        current_supply=EXCLUDED.current_supply,
                        is_honeypot=EXCLUDED.is_honeypot,
                        is_mintable=EXCLUDED.is_mintable,
                        risk_tier=EXCLUDED.risk_tier,
                        risk_score=EXCLUDED.risk_score,
                        risk_factors=EXCLUDED.risk_factors
                    """,
                    token.token_id, token.chain.value, token.address,
                    token.symbol, token.name, token.decimals,
                    token.deployer_wallet_id, token.deployed_at,
                    token.initial_supply, token.current_supply,
                    token.is_honeypot, token.is_mintable, token.is_proxy,
                    token.tax_buy_bps, token.tax_sell_bps,
                    token.risk_tier.value if token.risk_tier else None,
                    token.risk_score, token.risk_factors, token.rag_embedding_id,
                )
            # Invalidate cache
            if self._health.redis:
                with contextlib.suppress(Exception):
                    await self._redis.delete(
                        f"catalog:token:{token.chain.value}:{token.address}"
                    )
            return True
        except Exception as e:
            log.warning("token_save_fail: %s", e)
            return False

    # ── Wallets (Neo4j primary) ──────────────────────────────────────
    async def get_wallet(self, chain: Chain, address: str) -> Wallet | None:
        await self._init_stores()
        if not self._health.neo4j:
            return None
        wallet_id = f"{chain.value}:{address}"
        try:
            with self._neo_driver.session() as s:
                result = s.run(
                    "MATCH (w:Wallet {wallet_id: $id}) RETURN w",
                    id=wallet_id,
                ).single()
            if not result:
                return None
            node = dict(result["w"])
            # Normalize datetime strings
            for k in ("first_seen", "last_seen"):
                if isinstance(node.get(k), str):
                    from datetime import datetime
                    with contextlib.suppress(Exception):
                        node[k] = datetime.fromisoformat(node[k].replace("Z", "+00:00"))
            return Wallet(**node)
        except Exception as e:
            log.warning("wallet_get_fail: %s", e)
            return None

    async def save_wallet(self, wallet: Wallet) -> bool:
        await self._init_stores()
        if not self._health.neo4j:
            return False
        try:
            with self._neo_driver.session() as s:
                s.run(
                    """
                    MERGE (w:Wallet {wallet_id: $wallet_id})
                    SET w.chain=$chain, w.address=$address,
                        w.first_seen=$first_seen, w.last_seen=$last_seen,
                        w.tx_count=$tx_count, w.total_volume_usd=$total_volume_usd,
                        w.is_deployer=$is_deployer,
                        w.is_known_exchange=$is_known_exchange,
                        w.is_suspicious=$is_suspicious,
                        w.reputation_score=$reputation_score
                    """,
                    wallet_id=wallet.wallet_id,
                    chain=wallet.chain.value,
                    address=wallet.address,
                    first_seen=wallet.first_seen.isoformat(),
                    last_seen=wallet.last_seen.isoformat(),
                    tx_count=wallet.tx_count,
                    total_volume_usd=wallet.total_volume_usd,
                    is_deployer=wallet.is_deployer,
                    is_known_exchange=wallet.is_known_exchange,
                    is_suspicious=wallet.is_suspicious,
                    reputation_score=wallet.reputation_score,
                )
            return True
        except Exception as e:
            log.warning("wallet_save_fail: %s", e)
            return False

    # ── Recipe 1: find tokens by deployer history ───────────────────
    async def find_tokens_by_deployer_history(
        self, min_rug_count: int = 1, chain: Chain | None = None, limit: int = 50
    ) -> list[Token]:
        """Find tokens deployed by wallets that have rug-pulled before.

        Cross-store: Neo4j for the wallet filter, Postgres for the tokens.
        """
        await self._init_stores()
        if not (self._health.neo4j and self._health.postgres):
            log.warning("recipe1_stores_unavailable")
            return []
        try:
            # Step 1: Neo4j - find wallets with rug_count >= min_rug_count
            with self._neo_driver.session() as s:
                wallets = [
                    r["w.wallet_id"]
                    for r in s.run(
                        "MATCH (w:Deployer) WHERE w.rug_count >= $min "
                        "RETURN w.wallet_id LIMIT 200",
                        min=min_rug_count,
                    )
                ]
            if not wallets:
                return []
            # Step 2: Postgres - find tokens deployed by those wallets
            async with self._pg_pool.acquire() as conn:
                query = (
                    "SELECT * FROM tokens WHERE deployer_wallet_id = ANY($1::text[]) "
                )
                params: list[Any] = [wallets]
                if chain:
                    query += "AND chain=$2 "
                    params.append(chain.value)
                query += "ORDER BY deployed_at DESC LIMIT $3"
                params.append(limit)
                rows = await conn.fetch(query, *params)
            return [Token(**dict(r)) for r in rows]
        except Exception as e:
            log.warning("recipe1_fail: %s", e)
            return []

    # ── Recipe 3: get_token_risk (3-store composition) ──────────────
    async def get_token_risk(
        self, chain: Chain, address: str
    ) -> dict[str, Any]:
        """Real-time risk score - composes Redis cache + Postgres + Neo4j.

        Returns dict (not Token) because it's a cross-store projection.
        Cache TTL 60s.
        """
        await self._init_stores()
        cache_key = f"catalog:risk:{chain.value}:{address}"
        if self._health.redis:
            try:
                cached = await self._redis.get(cache_key)
                if cached:
                    return json.loads(cached)
            except Exception:
                pass

        token = await self.get_token(chain, address)
        deployer_reputation: int | None = None
        if token and token.deployer_wallet_id and self._health.neo4j:
            try:
                with self._neo_driver.session() as s:
                    r = s.run(
                        "MATCH (d:Deployer {wallet_id: $id}) "
                        "RETURN d.reputation_score AS rep, d.rug_count AS rugs",
                        id=token.deployer_wallet_id,
                    ).single()
                if r:
                    deployer_reputation = r["rep"]
            except Exception as e:
                log.debug("risk_neo4j_fail: %s", e)

        token_score = token.risk_score if token and token.risk_score is not None else 50
        if deployer_reputation is not None:
            score = int(0.6 * token_score + 0.4 * (100 - deployer_reputation))
        else:
            score = token_score
        score = max(0, min(100, score))
        if score < 25:
            tier = "low"
        elif score < 50:
            tier = "medium"
        elif score < 75:
            tier = "high"
        else:
            tier = "critical"
        result: dict[str, Any] = {
            "score": score,
            "tier": tier,
            "factors": token.risk_factors if token else [],
            "token": token.model_dump() if token else None,
            "deployer_reputation": deployer_reputation,
            "fetched_at": utcnow().isoformat(),
        }
        if self._health.redis:
            with contextlib.suppress(Exception):
                await self._redis.setex(cache_key, 60, json.dumps(result, default=str))
        return result

    # ── Recipe 5: resolve_entity (Neo4j Cypher) ─────────────────────
    async def resolve_entity(
        self, wallet_id: str, max_chains: int = 5
    ) -> dict[str, Any]:
        """Cross-chain entity resolution via Neo4j.

        Uses the SAME_AS / FUNDED_BY_SAME / CLONE_OF / BEHAVIORAL_MATCH
        edges defined in v4.0 §T32 with path-weighted confidence.
        """
        await self._init_stores()
        if not self._health.neo4j:
            return {"entity_id": None, "wallets": [], "note": "neo4j unavailable"}
        try:
            with self._neo_driver.session() as s:
                result = s.run(
                    """
                    MATCH (start:Wallet {wallet_id: $wallet_id})
                    OPTIONAL MATCH path = (start)-[:SAME_AS|FUNDED_BY_SAME|CLONE_OF|BEHAVIORAL_MATCH*1..3]-(other:Wallet)
                    WHERE start <> other
                    WITH start, other, path,
                         reduce(conf = 1.0, r IN relationships(path) | conf * r.confidence) AS path_conf
                    ORDER BY path_conf DESC
                    LIMIT $limit
                    RETURN start.entity_id AS entity_id,
                           collect({
                               wallet_id: other.wallet_id,
                               chain: other.chain,
                               address: other.address,
                               confidence: path_conf
                           }) AS cross_chain_wallets
                    """,
                    wallet_id=wallet_id, limit=max_chains,
                ).single()
            if not result:
                return {"entity_id": None, "wallets": [], "note": "no edges"}
            wallets = [w for w in (result["cross_chain_wallets"] or []) if w.get("wallet_id")]
            return {
                "entity_id": result["entity_id"],
                "wallets": wallets,
                "note": f"{len(wallets)} cross-chain matches",
            }
        except Exception as e:
            log.warning("resolve_entity_fail: %s", e)
            return {"entity_id": None, "wallets": [], "error": str(e)}

    # ── RAG bridge: wire app/rag/engine.py into the catalog ─────────
    async def rag_search(
        self, query: str, collection: str = "scam_intel", top_k: int = 5
    ) -> list[dict]:
        """Search the RAG system. Returns raw hits (catalog-agnostic).

        Use this when you want RAG results as raw search hits.
        Use get_token_risk / etc. when you want catalog-typed results.
        """
        try:
            return await rag_three_pillar_search(
                query=query, collection=collection, top_k=top_k
            )
        except Exception as e:
            log.warning("rag_search_fail: %s", e)
            return []

    async def rag_ingest(
        self,
        content: str,
        collection: str = "scam_intel",
        doc_id: str | None = None,
        metadata: dict | None = None,
    ) -> dict:
        """Ingest a fact into RAG and (if it's about a token) link to Token.rag_embedding_id.

        Returns the RAG ingest result plus the Qdrant point_id for cross-store ref.
        """
        from uuid import uuid4

        doc_id = doc_id or f"finding:{uuid4().hex[:12]}"
        try:
            r = await rag_ingest_document(
                collection=collection,
                doc_id=doc_id,
                content=content,
                metadata=metadata or {},
            )
            r["qdrant_point_id"] = doc_id  # Engine returns the same
            return r
        except Exception as e:
            log.warning("rag_ingest_fail: %s", e)
            return {"status": "failed", "error": str(e)}

    async def attach_rag_to_token(
        self, chain: Chain, address: str, qdrant_point_id: str
    ) -> bool:
        """Link an existing Qdrant point to a Token row as rag_embedding_id."""
        token = await self.get_token(chain, address)
        if not token:
            return False
        token.rag_embedding_id = qdrant_point_id
        return await self.save_token(token)

    # ── Stats / introspection ────────────────────────────────────────
    async def stats(self) -> dict[str, Any]:
        await self._init_stores()
        out: dict[str, Any] = {
            "stores": await self.probe_stores(),
            "collections": COLLECTIONS,
        }
        if self._health.postgres:
            try:
                async with self._pg_pool.acquire() as conn:
                    out["tokens"] = await conn.fetchval("SELECT COUNT(*) FROM tokens") or 0
                    out["alerts"] = await conn.fetchval("SELECT COUNT(*) FROM alerts") or 0
            except Exception as e:
                out["postgres_error"] = str(e)
        if self._health.neo4j:
            try:
                with self._neo_driver.session() as s:
                    out["wallets"] = s.run("MATCH (w:Wallet) RETURN COUNT(w) AS c").single()["c"] or 0
                    out["deployers"] = s.run("MATCH (d:Deployer) RETURN COUNT(d) AS c").single()["c"] or 0
            except Exception as e:
                out["neo4j_error"] = str(e)
        if self._health.qdrant:
            try:
                cols = await self._qdrant.get("/collections")
                if cols.status_code == 200:
                    out["qdrant_collections"] = [
                        c["name"] for c in cols.json().get("result", {}).get("collections", [])
                    ]
            except Exception as e:
                out["qdrant_error"] = str(e)
        return out


# ── Singleton accessor ──────────────────────────────────────────────
_catalog: CatalogService | None = None


def get_catalog() -> CatalogService:
    """Get the global CatalogService. Lazy-init on first call."""
    global _catalog
    if _catalog is None:
        _catalog = CatalogService()
    return _catalog
