from __future__ import annotations

import json
import time
from typing import Callable

from sqlalchemy import event
from sqlalchemy.orm import Session

from app.models.knowledge import (
    Knowledge,
    KnowledgeCard,
    KnowledgeChunk,
    KnowledgeManifest,
    KnowledgePublishLog,
    KnowledgeReviewRecord,
    KnowledgeSection,
    KnowledgeSourceSnapshot,
    KnowledgeSyncJob,
    KnowledgeVersion,
    UserKnowledgePermission,
)
from app.services.redis_client import get_sync_redis_client


_CACHE_INVALIDATION_PENDING = "agent_knowledge_cache_invalidation_pending"
_CACHE_RELEVANT_MODELS = (
    Knowledge,
    KnowledgeCard,
    KnowledgeChunk,
    KnowledgeManifest,
    KnowledgePublishLog,
    KnowledgeReviewRecord,
    KnowledgeSection,
    KnowledgeSourceSnapshot,
    KnowledgeSyncJob,
    KnowledgeVersion,
    UserKnowledgePermission,
)


class KnowledgeCatalogCacheService:
    """Shared generation for KnowledgeList and all Agent knowledge caches."""

    VERSION_KEY = "agent:knowledge:generation"
    KEY_PREFIX = "agent:knowledge:catalog:v2"
    METRICS_KEY = "agent:knowledge:cache:metrics"
    TTL_SECONDS = 300

    @classmethod
    def get_or_build(cls, variant: str, builder: Callable[[], list[dict]]) -> list[dict]:
        """Read-through cache. Redis is optional and never the source of truth."""
        client = get_sync_redis_client()
        if client is None:
            return builder()
        started = time.perf_counter()
        try:
            generation = client.get(cls.VERSION_KEY) or "1"
            key = f"{cls.KEY_PREFIX}:{generation}:{variant}"
            cached = client.get(key)
            if cached:
                client.hincrby(cls.METRICS_KEY, "hits", 1)
                client.hincrbyfloat(cls.METRICS_KEY, "hit_ms", (time.perf_counter() - started) * 1000)
                return json.loads(cached)
            client.hincrby(cls.METRICS_KEY, "misses", 1)
            value = builder()
            # Short lock prevents concurrent cold requests from repeatedly rebuilding.
            lock = client.set(f"{key}:lock", "1", nx=True, ex=15)
            if lock:
                client.setex(key, cls.TTL_SECONDS, json.dumps(value, ensure_ascii=False, default=str))
                client.delete(f"{key}:lock")
            client.hincrbyfloat(cls.METRICS_KEY, "miss_ms", (time.perf_counter() - started) * 1000)
            return value
        except Exception:
            return builder()

    @classmethod
    def invalidate(cls) -> None:
        """Logically expires every Agent knowledge cache in one atomic operation."""
        client = get_sync_redis_client()
        if client is None:
            return
        try:
            client.incr(cls.VERSION_KEY)
        except Exception:
            # Redis failure must not make a committed database change look unsuccessful;
            # subsequent reads automatically fall back to MySQL when Redis is unavailable.
            return


def register_knowledge_cache_invalidation_events() -> None:
    if event.contains(Session, "before_flush", _mark_knowledge_cache_dirty):
        return
    event.listen(Session, "before_flush", _mark_knowledge_cache_dirty)
    event.listen(Session, "after_commit", _invalidate_knowledge_cache_after_commit)
    event.listen(Session, "after_rollback", _clear_knowledge_cache_invalidation)


def _mark_knowledge_cache_dirty(session: Session, _flush_context: object, _instances: object) -> None:
    changed = list(session.new) + list(session.deleted)
    changed.extend(item for item in session.dirty if session.is_modified(item, include_collections=True))
    if any(isinstance(item, _CACHE_RELEVANT_MODELS) for item in changed):
        session.info[_CACHE_INVALIDATION_PENDING] = True


def _invalidate_knowledge_cache_after_commit(session: Session) -> None:
    if session.info.pop(_CACHE_INVALIDATION_PENDING, False):
        KnowledgeCatalogCacheService.invalidate()


def _clear_knowledge_cache_invalidation(session: Session) -> None:
    session.info.pop(_CACHE_INVALIDATION_PENDING, None)
