"""
Service layer cho CuCu Note với MongoDB backend.
Full features: memos, attachments, relations, reactions, embeddings, inbox, settings...
"""

from __future__ import annotations

import json
import logging
import os
from datetime import datetime, timezone
from typing import Any, List

from bson import ObjectId
import numpy as np

from . import schemas
from .mongodb import mongodb_client, generate_uid, utc_now, serialize_doc
from langchain_openai import OpenAIEmbeddings


class InstanceService:
    async def get_instance_info(self) -> schemas.InstanceInfo:
        return schemas.InstanceInfo(mode="dev", version="0.0.1-mongodb")

    async def update_instance_info(self, payload: schemas.InstanceUpdate) -> schemas.InstanceInfo:
        return schemas.InstanceInfo(mode="dev", version="0.0.1-mongodb")

    async def get_instance_setting(self) -> schemas.InstanceSetting:
        return schemas.InstanceSetting()


class AuthService:
    async def sign_in(self, payload: schemas.AuthSignInRequest) -> schemas.AuthSignInResponse:
        return schemas.AuthSignInResponse(accessToken="stub-token")

    async def sign_up(self, payload: schemas.AuthSignUpRequest) -> schemas.AuthSignUpResponse:
        return schemas.AuthSignUpResponse(user_id="1")

    async def get_me(self, token: str | None = None) -> schemas.AuthMeResponse:
        """
        Resolve current user for the memo frontend.

        Priority:
        1. If DISABLE_AUTH=true -> always return a stub user for local dev (no login required)
        2. If token is provided and Clerk is configured -> verify Clerk JWT and map to user
        3. If Clerk is not configured but token exists -> accept token and return demo user (dev mode)
        """
        import logging
        from config import DISABLE_AUTH, CLERK_JWKS_URL, CLERK_ISSUER

        # 1) Local dev mode: allow full features without auth
        if DISABLE_AUTH:
            logging.warning("⚠️  DISABLE_AUTH=true -> returning stub user for memo frontend")
            return schemas.AuthMeResponse(id="1", email="demo@example.com")

        # 2) Require token in normal mode
        if not token:
            raise ValueError("Missing authentication token")

        # 3) Verify Clerk JWT when configured
        try:
            # Only verify if Clerk is configured
            if CLERK_JWKS_URL and CLERK_ISSUER:
                from common.clerk_auth import verify_clerk_jwt

                payload = verify_clerk_jwt(token)
                # Extract user info from Clerk token
                user_id = payload.get("sub") or payload.get("user_id") or "1"
                email = payload.get("email") or "user@example.com"
                # Clerk provides these fields in the JWT
                username = payload.get("username") or payload.get("preferred_username")
                first_name = payload.get("first_name") or payload.get("given_name")
                last_name = payload.get("last_name") or payload.get("family_name")
                avatar_url = payload.get("image_url") or payload.get("picture")
                
                # Fallback username from email if not provided
                if not username and email:
                    username = email.split("@")[0]
                
                logging.info(f"✅ Clerk token verified for user: {user_id}, username: {username}")
                return schemas.AuthMeResponse(
                    id=user_id, 
                    email=email,
                    username=username,
                    first_name=first_name,
                    last_name=last_name,
                    avatar_url=avatar_url
                )
            else:
                # Clerk not configured - accept any token (dev mode)
                logging.warning("⚠️  Clerk not configured, accepting token without verification")
                return schemas.AuthMeResponse(id="1", email="demo@example.com")
        except Exception as e:
            # If Clerk verification fails, log and raise
            logging.error(f"❌ Clerk token verification failed: {e}")
            raise ValueError(f"Invalid authentication token: {str(e)}") from e


class UserService:
    async def list_users(self) -> List[schemas.UserResponse]:
        return []

    async def create_user(self, payload: schemas.UserCreate) -> schemas.UserResponse:
        email = payload.email or "demo@example.com"
        return schemas.UserResponse(id="1", email=email, nickname=None)

    async def get_user(self, user_id: str) -> schemas.UserResponse:
        return schemas.UserResponse(id=user_id, email="demo@example.com", nickname=None)

    async def update_user(self, user_id: str, payload: schemas.UserUpdate) -> schemas.UserResponse:
        return schemas.UserResponse(id=user_id, email=payload.email or "demo@example.com", nickname=payload.nickname)

    async def get_user_stats(self, user_id: str) -> schemas.UserStatsResponse:
        """
        Get user statistics including tag counts and memo timestamps.
        Tags are parsed from memo content using #tag pattern.
        """
        import re
        from .mongodb import mongodb_client
        
        # Query memos - try matching by creator_id
        # Handle both simple IDs (like "1") and Clerk user IDs (like "user_...")
        query: dict = {"row_status": {"$ne": "ARCHIVED"}}
        
        # If user_id looks like a Clerk ID or we want to match specific user
        if user_id and user_id != "1":
            query["creator_id"] = user_id
        # For simple "1", try both formats or get all memos for demo
        # In production, map "1" to actual Clerk user ID
        
        cursor = mongodb_client.memos.find(query, {"content": 1, "created_at": 1, "payload": 1})
        docs = await cursor.to_list(length=1000)
        
        tag_count: dict[str, int] = {}
        memo_timestamps: list[str] = []
        
        for doc in docs:
            # Collect timestamps
            created_at = doc.get("created_at")
            if created_at:
                if isinstance(created_at, datetime):
                    memo_timestamps.append(created_at.isoformat())
                else:
                    memo_timestamps.append(str(created_at))
            
            # Parse tags from content (#tag pattern) 
            content = doc.get("content", "")
            if content:
                matches = re.findall(r'#([^\s#]+)', content)
                for tag in matches:
                    tag_count[tag] = tag_count.get(tag, 0) + 1
            
            # Also check payload.tags if available
            payload_tags = doc.get("payload", {}).get("tags", [])
            for tag in payload_tags:
                if tag and tag not in tag_count:
                    tag_count[tag] = tag_count.get(tag, 0) + 1
        
        return schemas.UserStatsResponse(
            tag_count=tag_count,
            memo_display_timestamps=memo_timestamps,
        )


class MemoService:
    """Full-featured Memo service with MongoDB backend."""

    async def list_memos(
        self,
        user_id: str | None = None,
        creator_id: str | None = None,
        tag: str | None = None,
        visibility: str | None = None,
        pinned: bool | None = None,
        row_status: str | None = None,
        start_date: datetime | None = None,
        end_date: datetime | None = None,
    ) -> List[schemas.MemoResponse]:
        """
        List memos with filtering.
        """
        query: dict[str, Any] = {}

        if creator_id:
            # Specific creator requested (e.g. profile view)
            query["creator_id"] = creator_id

            # Access control for that creator's memos
            if user_id and creator_id == user_id:
                # Viewing own profile: show all
                pass
            else:
                # Viewing others' profile: blocked — each user only sees their own
                query["creator_id"] = "__none__"

        elif user_id and user_id != "anonymous":
            # Feed view (no specific creator): show ONLY user's own memos
            # Other users' PUBLIC/PROTECTED memos belong on the Explore page, not the home feed
            query["creator_id"] = user_id
        else:
            # Guest / anonymous: no memos — each user only sees their own
            query["creator_id"] = "__none__"

        # Apply filters
        if visibility:
            query["visibility"] = visibility
        if pinned is not None:
            query["pinned"] = pinned
        if row_status:
            query["row_status"] = row_status
        else:
            # Default: only show NORMAL (not archived)
            query["row_status"] = {"$ne": "ARCHIVED"}

        # Exclude comments by default (only show parent memos)
        query["parent"] = {"$exists": False}

        # Tag filter - search in content using regex for #tag pattern
        if tag:
            import re
            # Escape special regex characters in tag
            safe_tag = re.escape(tag)
            # Use $and to add tag filter without breaking existing $or
            tag_filter = {
                "$or": [
                    {"payload.tags": tag},  # Explicit tags array
                    {"content": {"$regex": f"#?{safe_tag}(\\s|$|[^a-zA-Z0-9_])", "$options": "i"}},  # Inline #tag in content
                ]
            }
            if "$and" in query:
                query["$and"].append(tag_filter)
            else:
                query["$and"] = [tag_filter]

        # Date Range filter
        date_filter: dict[str, Any] = {}
        if start_date:
            date_filter["$gte"] = start_date
        if end_date:
            date_filter["$lte"] = end_date

        if date_filter:
            query["created_at"] = date_filter

        cursor = mongodb_client.memos.find(query).sort("created_at", -1)
        docs = await cursor.to_list(length=100)

        # Batch fetch comment counts in 1 query (instead of N+1)
        memo_ids = [str(doc["_id"]) for doc in docs]
        comment_counts = await self._get_comment_counts_batch(memo_ids, user_id)

        memos: list[schemas.MemoResponse] = []
        for doc in docs:
            memo_response = self._doc_to_response(doc)
            memo_response.comment_count = comment_counts.get(str(doc["_id"]), 0)
            memos.append(memo_response)
        return memos

    async def create_memo(
        self,
        payload: schemas.MemoCreate,
        user_id: str | None = None,
        parent_id: str | None = None,
        anonymous_id: str | None = None,
    ) -> schemas.MemoResponse:
        """Create a memo for the specified user."""
        creator = user_id or "anonymous"
        now = payload.create_time or utc_now()
        if now.tzinfo is None:
            now = now.replace(tzinfo=timezone.utc)

        memo_payload = {
            "tags": payload.tags or [],
            "hasLink": False,
            "hasTaskList": False,
            "hasCode": False,
            "hasIncompleteTasks": False,
        }

        doc: dict[str, Any] = {
            "uid": generate_uid(),
            "creator_id": creator,
            "content": payload.content,
            "visibility": payload.visibility or "PRIVATE",
            "pinned": False,
            "payload": memo_payload,
            "row_status": "NORMAL",
            "created_at": now,
            "updated_at": now,
        }

        if parent_id:
            doc["parent"] = parent_id

        if anonymous_id:
            doc["anonymous_id"] = anonymous_id

        result = await mongodb_client.memos.insert_one(doc)
        doc["_id"] = result.inserted_id

        import asyncio
        asyncio.create_task(self._create_embedding(str(result.inserted_id), payload.content, payload.tags or [], user_id=user_id))

        # Sync to Meilisearch
        try:
            from common.meili_service import meili_service
            meili_service.index_memo(doc)
        except Exception:
            pass  # Search down ≠ app down

        return self._doc_to_response(doc)

    async def get_memo(
        self,
        memo_id: str,
        user_id: str | None = None,
    ) -> schemas.MemoResponse:
        """Get a memo by ID. Access control: owner or public."""
        doc = None
        if ObjectId.is_valid(memo_id):
            doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
        if not doc:
            doc = await mongodb_client.memos.find_one({"uid": memo_id})

        if not doc:
            raise ValueError(f"Memo {memo_id} not found")

        memo_creator = doc.get("creator_id", "anonymous")
        memo_visibility = doc.get("visibility", "PRIVATE")

        # Owner can always access
        if memo_creator == user_id:
            response = self._doc_to_response(doc)
            response.comment_count = await self._get_comment_count(str(doc["_id"]), memo_visibility, user_id)
            return response

        # PUBLIC: anyone can access (including anonymous)
        if memo_visibility == "PUBLIC":
            response = self._doc_to_response(doc)
            response.comment_count = await self._get_comment_count(str(doc["_id"]), memo_visibility, user_id)
            return response

        # PROTECTED: only logged-in users can access
        if memo_visibility == "PROTECTED":
            if user_id and user_id != "anonymous":
                response = self._doc_to_response(doc)
                response.comment_count = await self._get_comment_count(str(doc["_id"]), memo_visibility, user_id)
                return response
            raise ValueError("Access denied: PROTECTED memo requires authentication")

        # PRIVATE: only owner can access (handled above)
        raise ValueError(f"Access denied to memo {memo_id}")

    async def _get_comment_counts_batch(self, memo_ids: list[str], user_id: str | None = None) -> dict[str, int]:
        """Get comment counts for multiple memos in a single aggregation query.
        
        Returns a dict mapping memo_id -> comment_count.
        This replaces the N+1 pattern of calling _get_comment_count per memo.
        """
        if not memo_ids:
            return {}

        pipeline = [
            {
                "$match": {
                    "parent": {"$in": memo_ids},
                    "row_status": {"$ne": "ARCHIVED"},
                }
            },
            {
                "$group": {
                    "_id": "$parent",
                    "count": {"$sum": 1},
                }
            },
        ]

        cursor = mongodb_client.memos.aggregate(pipeline)
        results = await cursor.to_list(length=len(memo_ids))

        return {doc["_id"]: doc["count"] for doc in results}

    async def _get_comment_count(self, memo_id: str, memo_visibility: str, user_id: str | None = None) -> int:
        """Get comment count for a memo based on visibility and user access."""
        query: dict[str, Any] = {
            "parent": {"$in": [memo_id]},
            "row_status": {"$ne": "ARCHIVED"},
        }

        if memo_visibility == "PUBLIC":
            query["visibility"] = "PUBLIC"
        elif memo_visibility == "PROTECTED" and user_id:
            query["$or"] = [
                {"visibility": "PUBLIC"},
                {"visibility": "PROTECTED"},
            ]
        elif memo_visibility == "PRIVATE":
            if user_id:
                # Owner can see all comments
                pass
            else:
                return 0

        count = await mongodb_client.memos.count_documents(query)
        return count

    async def get_memo_versions(
        self,
        memo_id: str,
        user_id: str | None = None,
    ) -> schemas.MemoVersionResponse:
        """Get version history for a memo."""
        doc = None
        if ObjectId.is_valid(memo_id):
            doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
        if not doc:
            doc = await mongodb_client.memos.find_one({"uid": memo_id})

        if not doc:
            raise ValueError(f"Memo {memo_id} not found")

        versions_data = doc.get("versions", [])
        versions = [
            schemas.MemoVersion(
                version=v.get("version", i + 1),
                content=v.get("content", ""),
                created_at=v.get("created_at", utc_now()),
                created_by=v.get("created_by"),
            )
            for i, v in enumerate(versions_data)
        ]

        return schemas.MemoVersionResponse(
            versions=versions,
            total=len(versions),
        )

    async def update_memo(
        self,
        memo_id: str,
        payload: schemas.MemoUpdate,
        user_id: str | None = None,
    ) -> schemas.MemoResponse:
        """Update a memo. Only the owner can update."""
        doc = None
        if ObjectId.is_valid(memo_id):
            doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
        if not doc:
            doc = await mongodb_client.memos.find_one({"uid": memo_id})

        if not doc:
            raise ValueError(f"Memo {memo_id} not found")

        memo_creator = doc.get("creator_id", "anonymous")
        logging.info(f"🔍 update_memo ownership check: memo_id={memo_id}, memo_creator='{memo_creator}', user_id='{user_id}', match={memo_creator == user_id}")
        if memo_creator != user_id:
            raise ValueError(f"Access denied: you don't own memo {memo_id} (creator={memo_creator}, you={user_id})")

        update_fields: dict[str, Any] = {"updated_at": utc_now()}

        if payload.content is not None:
            update_fields["content"] = payload.content
        if payload.visibility is not None:
            update_fields["visibility"] = payload.visibility
        if payload.tags is not None:
            update_fields["payload.tags"] = payload.tags
        if payload.pinned is not None:
            update_fields["pinned"] = payload.pinned
        if payload.row_status is not None:
            update_fields["row_status"] = payload.row_status

        # Save version history when content changes
        if payload.content is not None and payload.content != doc.get("content", ""):
            existing_versions = doc.get("versions", [])
            new_version = {
                "version": len(existing_versions) + 1,
                "content": doc.get("content", ""),
                "created_at": doc.get("updated_at") or doc.get("created_at") or utc_now(),
                "created_by": memo_creator,
            }
            update_fields["versions"] = existing_versions + [new_version]

        await mongodb_client.memos.update_one(
            {"_id": doc["_id"]},
            {"$set": update_fields},
        )

        if payload.content is not None:
            import asyncio
            asyncio.create_task(self._create_embedding(
                str(doc["_id"]),
                payload.content,
                payload.tags or doc.get("payload", {}).get("tags", []),
                user_id=user_id,
            ))

        # Sync updated memo to Meilisearch
        try:
            from common.meili_service import meili_service
            updated = await mongodb_client.memos.find_one({"_id": doc["_id"]})
            if updated:
                meili_service.index_memo(updated)
        except Exception:
            pass  # Search down ≠ app down

        return await self.get_memo(str(doc["_id"]), user_id=user_id)

    async def delete_memo(self, memo_id: str, user_id: str | None = None) -> None:
        """Delete a memo. Only the owner can delete."""
        doc = None
        if ObjectId.is_valid(memo_id):
            doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
        if not doc:
            doc = await mongodb_client.memos.find_one({"uid": memo_id})

        if not doc:
            raise ValueError(f"Memo {memo_id} not found")

        memo_creator = doc.get("creator_id", "anonymous")
        logging.info(f"🔍 delete_memo ownership check: memo_id={memo_id}, memo_creator='{memo_creator}', user_id='{user_id}', match={memo_creator == user_id}")
        if memo_creator != user_id:
            raise ValueError(f"Access denied: you don't own memo {memo_id} (creator={memo_creator}, you={user_id})")

        await mongodb_client.memos.delete_one({"_id": doc["_id"]})
        await mongodb_client.memo_embeddings.delete_many({"memo_id": str(doc["_id"])})
        await mongodb_client.attachments.delete_many({"memo_id": str(doc["_id"])})
        await mongodb_client.memo_relations.delete_many(
            {
                "$or": [
                    {"memo_id": str(doc["_id"])},
                    {"related_memo_id": str(doc["_id"])},
                ]
            }
        )
        await mongodb_client.reactions.delete_many({"content_id": str(doc["_id"])})

        # Remove from Meilisearch
        try:
            from common.meili_service import meili_service
            meili_service.delete_memo(str(doc["_id"]))
        except Exception:
            pass  # Search down ≠ app down

    async def pin_memo(self, memo_id: str, pinned: bool, user_id: str | None = None) -> schemas.MemoResponse:
        """Pin or unpin a memo."""
        return await self.update_memo(
            memo_id,
            schemas.MemoUpdate(pinned=pinned),
            user_id=user_id,
        )

    async def archive_memo(self, memo_id: str, user_id: str | None = None) -> schemas.MemoResponse:
        """Archive a memo."""
        return await self.update_memo(
            memo_id,
            schemas.MemoUpdate(row_status="ARCHIVED"),
            user_id=user_id,
        )

    async def restore_memo(self, memo_id: str, user_id: str | None = None) -> schemas.MemoResponse:
        """Restore an archived memo."""
        return await self.update_memo(
            memo_id,
            schemas.MemoUpdate(row_status="NORMAL"),
            user_id=user_id,
        )

    def _doc_to_response(self, doc: dict) -> schemas.MemoResponse:
        """Convert MongoDB document to MemoResponse."""
        payload = doc.get("payload", {})
        created_at = doc.get("created_at")
        updated_at = doc.get("updated_at")

        if isinstance(created_at, datetime):
            if created_at.tzinfo:
                created_at = created_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
            else:
                created_at = created_at.strftime("%Y-%m-%dT%H:%M:%SZ")
        if isinstance(updated_at, datetime):
            if updated_at.tzinfo:
                updated_at = updated_at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
            else:
                updated_at = updated_at.strftime("%Y-%m-%dT%H:%M:%SZ")

        creator_id = doc.get("creator_id", "anonymous")
        uid = doc.get("uid", "")
        
        return schemas.MemoResponse(
            id=str(doc["_id"]),
            uid=uid,
            content=doc.get("content", ""),
            visibility=doc.get("visibility", "PRIVATE"),
            tags=payload.get("tags", []),
            pinned=doc.get("pinned", False),
            row_status=doc.get("row_status", "NORMAL"),
            creator_id=creator_id,
            creator=f"users/{creator_id}",  # Resource format for frontend
            name=f"memos/{uid}",  # Resource format for frontend
            create_time=created_at,
            update_time=updated_at,
            display_time=created_at,
            parent=doc.get("parent"),
        )

    async def _create_embedding(self, memo_id: str, content: str, tags: list[str], user_id: str | None = None) -> None:
        """
        Create or update embedding for a memo.
        Uses user's API key if available, otherwise falls back to env key.
        
        Args:
            memo_id: Memo ID
            content: Memo content to embed
            tags: Memo tags
            user_id: Optional user ID to use user's API key
        """
        try:
            # Try to get user's API key first
            api_key = None
            if user_id:
                from common.memos_core.services import get_user_settings_service
                settings_service = get_user_settings_service()
                api_key = await settings_service.get_openai_api_key(user_id)
            
            # Fallback to env key
            if not api_key:
                api_key = os.getenv("OPENAI_API_KEY")
            
            if not api_key:
                return

            # Use async embedding service which supports user keys
            from common.embedding_service import create_embedding_async
            vec = await create_embedding_async(content, user_id=user_id)

            if not vec:
                return

            now = utc_now()
            date_key = now.strftime("%Y-%m-%d")

            await mongodb_client.memo_embeddings.update_one(
                {"memo_id": memo_id},
                {
                    "$set": {
                        "memo_id": memo_id,
                        "content": content,
                        "tags": tags,
                        "date_key": date_key,
                        "embedding": vec,
                        "dim": len(vec),
                        "model": "text-embedding-3-small",
                        "updated_at": now,
                    },
                    "$setOnInsert": {
                        "created_at": now,
                    },
                },
                upsert=True,
            )
        except Exception as e:
            import logging

            logging.warning(f"Failed to create embedding: {e}")


class AttachmentService:
    """Attachment service with MongoDB backend."""

    async def list_attachments(self, memo_id: str | None = None) -> List[schemas.AttachmentResponse]:
        query: dict[str, Any] = {}
        if memo_id:
            query["memo_id"] = memo_id

        cursor = mongodb_client.attachments.find(query)
        docs = await cursor.to_list(length=100)

        return [self._doc_to_response(doc) for doc in docs]

    async def upload_attachment(self, file, memo_id: str | None = None, user_id: str | None = None) -> schemas.AttachmentResponse:
        now = utc_now()

        doc = {
            "uid": generate_uid(),
            "creator_id": user_id or "anonymous",
            "filename": getattr(file, "filename", "upload.bin"),
            "type": getattr(file, "content_type", "application/octet-stream"),
            "size": 0,
            "storage_type": "LOCAL",
            "reference": "",
            "memo_id": memo_id,
            "created_at": now,
            "updated_at": now,
        }

        result = await mongodb_client.attachments.insert_one(doc)
        doc["_id"] = result.inserted_id

        return self._doc_to_response(doc)

    async def get_attachment(self, attachment_id: str) -> schemas.AttachmentResponse:
        doc = None
        if ObjectId.is_valid(attachment_id):
            doc = await mongodb_client.attachments.find_one({"_id": ObjectId(attachment_id)})
        if not doc:
            doc = await mongodb_client.attachments.find_one({"uid": attachment_id})

        if not doc:
            raise ValueError(f"Attachment {attachment_id} not found")

        return self._doc_to_response(doc)

    async def delete_attachment(self, attachment_id: str, user_id: str | None = None) -> None:
        doc = None
        if ObjectId.is_valid(attachment_id):
            doc = await mongodb_client.attachments.find_one({"_id": ObjectId(attachment_id)})
        if not doc:
            doc = await mongodb_client.attachments.find_one({"uid": attachment_id})

        if not doc:
            raise ValueError(f"Attachment {attachment_id} not found")

        if doc.get("creator_id") != user_id:
            raise ValueError("Access denied")

        await mongodb_client.attachments.delete_one({"_id": doc["_id"]})

    def _doc_to_response(self, doc: dict) -> schemas.AttachmentResponse:
        return schemas.AttachmentResponse(
            id=str(doc["_id"]),
            uid=doc.get("uid", ""),
            filename=doc.get("filename", ""),
            url=doc.get("reference", ""),
            type=doc.get("type", ""),
            size=doc.get("size", 0),
        )


class MemoRelationService:
    """Memo relations service (comments, references)."""

    async def create_relation(
        self,
        memo_id: str,
        related_memo_id: str,
        relation_type: str = "REFERENCE",
    ) -> dict:
        now = utc_now()

        doc = {
            "memo_id": memo_id,
            "related_memo_id": related_memo_id,
            "type": relation_type,
            "created_at": now,
        }

        await mongodb_client.memo_relations.insert_one(doc)
        return serialize_doc(doc)

    async def list_relations(self, memo_id: str, related_memo_id: str | None = None, relation_type: str | None = None) -> list[dict]:
        """List memo relations."""
        query: dict[str, Any] = {
            "$or": [
                {"memo_id": memo_id},
                {"related_memo_id": memo_id},
            ]
        }

        if related_memo_id:
            query["related_memo_id"] = related_memo_id
        if relation_type:
            query["type"] = relation_type

        cursor = mongodb_client.memo_relations.find(query)
        docs = await cursor.to_list(length=100)
        return [serialize_doc(doc) for doc in docs]

    async def delete_relation(self, memo_id: str, related_memo_id: str) -> None:
        await mongodb_client.memo_relations.delete_one(
            {
                "memo_id": memo_id,
                "related_memo_id": related_memo_id,
            }
        )


class ReactionService:
    """Reactions service (emoji reactions)."""

    async def add_reaction(
        self,
        content_id: str,
        reaction_type: str,
        user_id: str | None = None,
    ) -> dict:
        now = utc_now()

        doc = {
            "content_id": content_id,
            "creator_id": user_id or "anonymous",
            "reaction_type": reaction_type,
            "created_at": now,
        }

        await mongodb_client.reactions.update_one(
            {
                "content_id": content_id,
                "creator_id": user_id or "anonymous",
                "reaction_type": reaction_type,
            },
            {"$set": doc},
            upsert=True,
        )

        return serialize_doc(doc)

    async def list_reactions(self, content_id: str) -> list[dict]:
        cursor = mongodb_client.reactions.find({"content_id": content_id})
        docs = await cursor.to_list(length=100)
        return [serialize_doc(doc) for doc in docs]

    async def remove_reaction(
        self,
        content_id: str,
        reaction_type: str,
        user_id: str | None = None,
    ) -> None:
        await mongodb_client.reactions.delete_one(
            {
                "content_id": content_id,
                "creator_id": user_id or "anonymous",
                "reaction_type": reaction_type,
            }
        )


class ShortcutService:
    """Shortcut (Workspace) service with MongoDB backend."""

    async def list_shortcuts(self) -> List[schemas.ShortcutResponse]:
        cursor = mongodb_client.shortcuts.find({}).sort("created_at", -1)
        docs = await cursor.to_list(length=100)
        return [
            schemas.ShortcutResponse(
                id=str(doc["_id"]),
                name=doc.get("name", ""),
                filter=doc.get("filter", ""),
            )
            for doc in docs
        ]

    async def create_shortcut(self, payload: schemas.ShortcutCreate) -> schemas.ShortcutResponse:
        now = utc_now()
        doc = {
            "name": payload.name,
            "filter": payload.filter,
            "created_at": now,
            "updated_at": now,
        }
        result = await mongodb_client.shortcuts.insert_one(doc)
        doc["_id"] = result.inserted_id
        return schemas.ShortcutResponse(
            id=str(doc["_id"]),
            name=doc["name"],
            filter=doc["filter"],
        )

    async def update_shortcut(self, shortcut_id: str, payload: schemas.ShortcutUpdate) -> schemas.ShortcutResponse:
        update_fields: dict[str, Any] = {"updated_at": utc_now()}
        if payload.name is not None:
            update_fields["name"] = payload.name
        if payload.filter is not None:
            update_fields["filter"] = payload.filter

        filter_query: dict[str, Any] = {}
        if ObjectId.is_valid(shortcut_id):
            filter_query["_id"] = ObjectId(shortcut_id)
        else:
            filter_query["_id"] = shortcut_id

        from pymongo import ReturnDocument
        result = await mongodb_client.shortcuts.find_one_and_update(
            filter_query,
            {"$set": update_fields},
            return_document=ReturnDocument.AFTER,
        )
        if not result:
            raise ValueError(f"Shortcut {shortcut_id} not found")

        return schemas.ShortcutResponse(
            id=str(result["_id"]),
            name=result.get("name", ""),
            filter=result.get("filter", ""),
        )

    async def delete_shortcut(self, shortcut_id: str) -> None:
        filter_query: dict[str, Any] = {}
        if ObjectId.is_valid(shortcut_id):
            filter_query["_id"] = ObjectId(shortcut_id)
        else:
            filter_query["_id"] = shortcut_id

        result = await mongodb_client.shortcuts.delete_one(filter_query)
        if result.deleted_count == 0:
            raise ValueError(f"Shortcut {shortcut_id} not found")


class ActivityService:
    """Service for managing activity records (e.g., memo comments)"""
    
    async def list_activities(self) -> List[schemas.ActivityResponse]:
        """List all activities"""
        activities = []
        cursor = mongodb_client.activities.find({}).sort("created_at", -1).limit(100)
        async for doc in cursor:
            activities.append(self._doc_to_response(doc))
        return activities
    
    async def get_activity(self, activity_id: int) -> Optional[schemas.ActivityResponse]:
        """Get a single activity by ID"""
        doc = await mongodb_client.activities.find_one({"_id": activity_id})
        if not doc:
            return None
        return self._doc_to_response(doc)
    
    async def create_activity(
        self,
        activity_type: str,
        creator_id: str,
        memo_id: str,
        related_memo_id: str
    ) -> schemas.ActivityResponse:
        """Create a new activity record"""
        from datetime import datetime, timezone
        import random
        
        # Generate a numeric ID (simulating auto-increment)
        activity_id = random.randint(100000, 999999)
        
        now = datetime.now(timezone.utc)
        doc = {
            "_id": activity_id,
            "type": activity_type,
            "creator_id": creator_id,
            "memo_id": memo_id,
            "related_memo_id": related_memo_id,
            "level": "INFO",
            "created_at": now,
        }
        await mongodb_client.activities.insert_one(doc)
        return self._doc_to_response(doc)
    
    def _doc_to_response(self, doc: dict) -> schemas.ActivityResponse:
        """Convert MongoDB document to ActivityResponse"""
        created_at = doc.get("created_at")
        create_time_str = created_at.isoformat() if created_at else None
        
        payload = None
        if doc.get("memo_id") and doc.get("related_memo_id"):
            payload = schemas.ActivityPayload(
                memo_comment=schemas.ActivityMemoCommentPayload(
                    memo=f"memos/{doc['memo_id']}",
                    related_memo=f"memos/{doc['related_memo_id']}"
                )
            )
        
        return schemas.ActivityResponse(
            name=f"activities/{doc['_id']}",
            creator=f"users/{doc.get('creator_id', 'unknown')}",
            type=doc.get("type", "MEMO_COMMENT"),
            level=doc.get("level", "INFO"),
            create_time=create_time_str,
            payload=payload
        )


class NotificationService:
    """Service for managing user notifications (Inbox)"""
    
    async def list_notifications(self, user_id: str) -> List[schemas.NotificationResponse]:
        """List all notifications for a user"""
        notifications = []
        # Find notifications where recipient matches user_id
        cursor = mongodb_client.notifications.find(
            {"recipient_id": user_id}
        ).sort("created_at", -1).limit(100)
        async for doc in cursor:
            notifications.append(self._doc_to_response(doc))
        return notifications
    
    async def create_notification(
        self,
        recipient_id: str,
        sender_id: str,
        activity_id: int,
        notification_type: str = "MEMO_COMMENT"
    ) -> schemas.NotificationResponse:
        """Create a notification for a user"""
        from datetime import datetime, timezone
        import random
        
        notification_id = random.randint(100000, 999999)
        now = datetime.now(timezone.utc)
        
        doc = {
            "_id": notification_id,
            "recipient_id": recipient_id,
            "sender_id": sender_id,
            "activity_id": activity_id,
            "type": notification_type,
            "status": "UNREAD",
            "created_at": now,
        }
        await mongodb_client.notifications.insert_one(doc)
        return self._doc_to_response(doc)
    
    async def update_notification(
        self,
        notification_id: int,
        status: str
    ) -> Optional[schemas.NotificationResponse]:
        """Update notification status (READ, ARCHIVED)"""
        result = await mongodb_client.notifications.find_one_and_update(
            {"_id": notification_id},
            {"$set": {"status": status}},
            return_document=True
        )
        if not result:
            return None
        return self._doc_to_response(result)
    
    async def delete_notification(self, notification_id: int) -> bool:
        """Delete a notification"""
        result = await mongodb_client.notifications.delete_one({"_id": notification_id})
        return result.deleted_count > 0
    
    def _doc_to_response(self, doc: dict) -> schemas.NotificationResponse:
        """Convert MongoDB document to NotificationResponse"""
        created_at = doc.get("created_at")
        create_time_str = created_at.isoformat() if created_at else None
        
        return schemas.NotificationResponse(
            name=f"notifications/{doc['_id']}",
            sender=doc.get("sender_id", "unknown"),
            activity_id=doc.get("activity_id", 0),
            type=doc.get("type", "MEMO_COMMENT"),
            status=doc.get("status", "UNREAD"),
            create_time=create_time_str
        )


class IdentityProviderService:
    async def list_identity_providers(self) -> List[schemas.IdentityProviderResponse]:
        return []

    async def create_identity_provider(
        self,
        payload: schemas.IdentityProviderCreate,
    ) -> schemas.IdentityProviderResponse:
        return schemas.IdentityProviderResponse(id=1, name=payload.name, type=payload.type)

    async def update_identity_provider(
        self,
        idp_id: int,
        payload: schemas.IdentityProviderUpdate,
    ) -> schemas.IdentityProviderResponse:
        return schemas.IdentityProviderResponse(id=idp_id, name=payload.name or "demo", type="oidc")


class MemoEmbeddingService:
    """Embedding service for semantic search."""

    async def search_embeddings(
        self,
        query_embedding: list[float],
        top_k: int = 5,
        user_id: str | None = None,
    ) -> list[dict]:
        """Search memos by embedding similarity using server-side aggregation."""
        q = np.array(query_embedding, dtype=float)
        q_norm = float(np.linalg.norm(q))
        if q_norm == 0:
            return []

        # Normalize query vector once (for cosine similarity)
        q_normalized = (q / q_norm).tolist()

        # Build match filter — only load this user's embeddings
        match_filter: dict[str, Any] = {}
        if user_id and user_id != "anonymous":
            match_filter["creator_id"] = user_id

        # Use aggregation pipeline to compute cosine similarity server-side
        pipeline: list[dict[str, Any]] = []

        if match_filter:
            pipeline.append({"$match": match_filter})

        # Filter docs that have embeddings
        pipeline.append({"$match": {"embedding": {"$exists": True, "$ne": []}}})

        # Compute dot product with normalized query (= cosine sim if embeddings are normalized)
        # Using $reduce to compute dot product server-side
        pipeline.extend([
            {
                "$addFields": {
                    "score": {
                        "$reduce": {
                            "input": {"$zip": {"inputs": ["$embedding", q_normalized]}},
                            "initialValue": 0,
                            "in": {
                                "$add": [
                                    "$$value",
                                    {"$multiply": [
                                        {"$arrayElemAt": ["$$this", 0]},
                                        {"$arrayElemAt": ["$$this", 1]},
                                    ]},
                                ]
                            },
                        }
                    }
                }
            },
            {"$sort": {"score": -1}},
            {"$limit": top_k},
            {
                "$project": {
                    "_id": 0,
                    "memo_id": 1,
                    "content": 1,
                    "tags": 1,
                    "score": 1,
                }
            },
        ])

        cursor = mongodb_client.memo_embeddings.aggregate(pipeline)
        results = await cursor.to_list(length=top_k)
        return results


class InboxService:
    """Inbox notifications service - notifies users of comments/mentions."""

    async def create_notification(
        self,
        sender_id: str,
        receiver_id: str,
        message_type: str,
        memo_id: str | None = None,
        content: str | None = None,
    ) -> dict:
        now = utc_now()

        doc = {
            "sender_id": sender_id,
            "receiver_id": receiver_id,
            "status": "UNREAD",
            "message": {
                "type": message_type,
                "memo_id": memo_id,
                "content": content,
            },
            "created_at": now,
        }

        result = await mongodb_client.inbox.insert_one(doc)
        doc["_id"] = result.inserted_id
        return serialize_doc(doc)

    async def list_notifications(
        self,
        receiver_id: str,
        status: str | None = None,
    ) -> list[dict]:
        query: dict[str, Any] = {"receiver_id": receiver_id}
        if status:
            query["status"] = status

        cursor = mongodb_client.inbox.find(query).sort("created_at", -1)
        docs = await cursor.to_list(length=50)
        return [serialize_doc(doc) for doc in docs]

    async def mark_as_read(self, notification_id: str, receiver_id: str) -> dict | None:
        from bson import ObjectId

        result = await mongodb_client.inbox.find_one_and_update(
            {"_id": ObjectId(notification_id), "receiver_id": receiver_id},
            {"$set": {"status": "READ"}},
            return_document=True,
        )
        return serialize_doc(result) if result else None

    async def mark_all_as_read(self, receiver_id: str) -> int:
        result = await mongodb_client.inbox.update_many(
            {"receiver_id": receiver_id, "status": "UNREAD"},
            {"$set": {"status": "READ"}},
        )
        return result.modified_count

    async def delete_notification(self, notification_id: str, receiver_id: str) -> bool:
        from bson import ObjectId

        result = await mongodb_client.inbox.delete_one(
            {
                "_id": ObjectId(notification_id),
                "receiver_id": receiver_id,
            }
        )
        return result.deleted_count > 0

    async def get_unread_count(self, receiver_id: str) -> int:
        return await mongodb_client.inbox.count_documents(
            {
                "receiver_id": receiver_id,
                "status": "UNREAD",
            }
        )


class UserSettingsService:
    """User settings/preferences service."""

    async def get_settings(self, user_id: str) -> dict:
        doc = await mongodb_client.user_settings.find_one({"user_id": user_id})

        if not doc:
            default = {
                "user_id": user_id,
                "locale": "en",
                "theme": "system",
                "memo_visibility": "PRIVATE",
                "enable_notifications": True,
                "created_at": utc_now(),
                "updated_at": utc_now(),
            }
            await mongodb_client.user_settings.insert_one(default)
            return serialize_doc(default)

        return serialize_doc(doc)

    async def update_settings(self, user_id: str, updates: dict) -> dict:
        allowed_fields = {
            "locale",
            "theme",
            "memo_visibility",
            "enable_notifications",
            "timezone",
        }
        filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields}
        filtered_updates["updated_at"] = utc_now()

        result = await mongodb_client.user_settings.find_one_and_update(
            {"user_id": user_id},
            {"$set": filtered_updates},
            upsert=True,
            return_document=True,
        )
        return serialize_doc(result)

    async def get_openai_api_key(self, user_id: str) -> str | None:
        """
        Get user's OpenAI API key (decrypted).
        
        Args:
            user_id: User ID
            
        Returns:
            Decrypted API key or None if not set
        """
        from common.encryption import decrypt_api_key
        
        doc = await mongodb_client.user_settings.find_one({"user_id": user_id})
        if not doc:
            return None
        
        encrypted_key = doc.get("openai_api_key_encrypted")
        if not encrypted_key:
            return None
        
        try:
            return decrypt_api_key(encrypted_key)
        except Exception as e:
            logging.warning(f"Failed to decrypt OpenAI key for user {user_id}: {e}")
            return None

    async def set_openai_api_key(self, user_id: str, api_key: str) -> dict:
        """
        Set user's OpenAI API key (encrypted).
        
        Args:
            user_id: User ID
            api_key: Plain text API key to encrypt and store
            
        Returns:
            Updated settings document
        """
        from common.encryption import encrypt_api_key, validate_openai_key_format
        
        # Validate key format
        if not validate_openai_key_format(api_key):
            raise ValueError("Invalid OpenAI API key format. Key must start with 'sk-' and be 20-100 characters.")
        
        # Encrypt the key
        encrypted_key = encrypt_api_key(api_key)
        
        # Update or create settings
        result = await mongodb_client.user_settings.find_one_and_update(
            {"user_id": user_id},
            {
                "$set": {
                    "openai_api_key_encrypted": encrypted_key,
                    "updated_at": utc_now(),
                },
                "$setOnInsert": {
                    "user_id": user_id,
                    "locale": "en",
                    "theme": "system",
                    "memo_visibility": "PRIVATE",
                    "enable_notifications": True,
                    "created_at": utc_now(),
                },
            },
            upsert=True,
            return_document=True,
        )
        return serialize_doc(result)

    async def delete_openai_api_key(self, user_id: str) -> dict:
        """
        Delete user's OpenAI API key.
        
        Args:
            user_id: User ID
            
        Returns:
            Updated settings document
        """
        result = await mongodb_client.user_settings.find_one_and_update(
            {"user_id": user_id},
            {
                "$unset": {"openai_api_key_encrypted": ""},
                "$set": {"updated_at": utc_now()},
            },
            return_document=True,
        )
        if result:
            return serialize_doc(result)
        # If user settings don't exist, return empty dict
        return {}

    async def has_openai_api_key(self, user_id: str) -> bool:
        """
        Check if user has an OpenAI API key set.
        
        Args:
            user_id: User ID
            
        Returns:
            True if key exists, False otherwise
        """
        doc = await mongodb_client.user_settings.find_one(
            {"user_id": user_id},
            {"openai_api_key_encrypted": 1}
        )
        return bool(doc and doc.get("openai_api_key_encrypted"))


class S3StorageService:
    """S3 storage service for attachments."""

    def __init__(self) -> None:
        self.bucket_name = os.getenv("S3_BUCKET_NAME", "cucu-note-attachments")
        self.region = os.getenv("AWS_REGION", "ap-southeast-1")
        self._client = None

    def _get_client(self):
        if self._client is None:
            try:
                import boto3

                self._client = boto3.client(
                    "s3",
                    region_name=self.region,
                    aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
                    aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
                )
            except ImportError:
                raise RuntimeError("boto3 not installed. Run: pip install boto3")
        return self._client

    async def upload_file(
        self,
        file_content: bytes,
        filename: str,
        content_type: str = "application/octet-stream",
        user_id: str = "anonymous",
    ) -> dict:
        import asyncio

        timestamp = datetime.utcnow().strftime("%Y/%m/%d")
        key = f"{user_id}/{timestamp}/{generate_uid()}_{filename}"

        loop = asyncio.get_event_loop()
        await loop.run_in_executor(
            None,
            lambda: self._get_client().put_object(
                Bucket=self.bucket_name,
                Key=key,
                Body=file_content,
                ContentType=content_type,
            ),
        )

        url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{key}"

        return {
            "key": key,
            "url": url,
            "bucket": self.bucket_name,
            "size": len(file_content),
        }

    async def delete_file(self, key: str) -> bool:
        import asyncio

        try:
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(
                None,
                lambda: self._get_client().delete_object(
                    Bucket=self.bucket_name,
                    Key=key,
                ),
            )
            return True
        except Exception:
            return False

    async def get_presigned_url(self, key: str, expires_in: int = 3600) -> str:
        import asyncio

        loop = asyncio.get_event_loop()
        url = await loop.run_in_executor(
            None,
            lambda: self._get_client().generate_presigned_url(
                "get_object",
                Params={"Bucket": self.bucket_name, "Key": key},
                ExpiresIn=expires_in,
            ),
        )
        return url


# =========================================================================
# Dependency providers
# =========================================================================


def get_instance_service() -> InstanceService:
    return InstanceService()


def get_auth_service() -> AuthService:
    return AuthService()


def get_user_service() -> UserService:
    return UserService()


def get_memo_service() -> MemoService:
    return MemoService()


def get_attachment_service() -> AttachmentService:
    return AttachmentService()


def get_shortcut_service() -> ShortcutService:
    return ShortcutService()


def get_activity_service() -> ActivityService:
    return ActivityService()


def get_notification_service() -> NotificationService:
    return NotificationService()


def get_idp_service() -> IdentityProviderService:
    return IdentityProviderService()


def get_memo_relation_service() -> MemoRelationService:
    return MemoRelationService()


def get_reaction_service() -> ReactionService:
    return ReactionService()


def get_memo_embedding_service() -> MemoEmbeddingService:
    return MemoEmbeddingService()


def get_inbox_service() -> InboxService:
    return InboxService()


def get_user_settings_service() -> UserSettingsService:
    return UserSettingsService()


def get_s3_storage_service() -> S3StorageService:
    return S3StorageService()


