"""Unified knowledge store: SQLite + pluggable vector backend."""

import json
import logging
from datetime import UTC, datetime, timedelta

from sqlalchemy import func, select

from sulci_core.config import SulciConfig
from sulci_core.embeddings import EmbeddingGenerator
from sulci_core.models import (
    AtomType,
    ConflictStatus,
    ConflictType,
    ContextInjection,
    ContextQueryResult,
    DataMode,
    Interaction,
    KnowledgeAtom,
    KnowledgeConflict,
    Project,
    ProjectStatus,
    ProjectType,
    VerificationStatus,
)
from sulci_core.schema import (
    ContextInjectionRow,
    DismissedSuggestionRow,
    InteractionRow,
    KnowledgeAtomRow,
    KnowledgeConflictRow,
    ProjectRow,
    init_db,
)

logger = logging.getLogger(__name__)

# Sources that produce high-volume, multi-valued atoms (integration connectors)
INTEGRATION_SOURCE_TOOLS = {"gmail", "google_calendar", "google_docs", "slack"}

# Predicates that are inherently multi-valued — different values are not contradictions
MULTI_VALUED_PREDICATE_KEYWORDS = {
    "attendees",
    "participants",
    "recipients",
    "members",
    "features",
    "topics",
    "tags",
    "labels",
    "categories",
    "items",
    "events",
    "messages",
    "files",
    "documents",
    "channels",
    "contacts",
}


class KnowledgeStore:
    """Unified storage layer for knowledge atoms, interactions, and injections."""

    def __init__(self, config: SulciConfig) -> None:
        self.config = config
        self._session_factory = None
        self._vector_backend = None
        self._embedder = EmbeddingGenerator(config)
        self._encryptor = None
        # When set, all store operations are scoped to this user by default.
        # Used by MCP server / proxy in multi-tenant mode via SULCI_USER_ID.
        self.default_user_id: str | None = config.user_id or None

    def _uid(self, user_id: str | None) -> str | None:
        """Return user_id, falling back to default_user_id when not explicitly provided."""
        return user_id if user_id is not None else self.default_user_id

    def _get_encryptor(self):
        """Get the encryption manager (lazy init, only when encryption is enabled)."""
        if self._encryptor is None and self.config.encryption_enabled:
            from sulci_core.encryption import EncryptionManager

            self._encryptor = EncryptionManager(self.config.data_dir)
        return self._encryptor if self.config.encryption_enabled else None

    def _encrypt_field(self, value: str | None) -> str | None:
        """Encrypt a field value if encryption is enabled."""
        if not value:
            return value
        enc = self._get_encryptor()
        return enc.encrypt(value) if enc else value

    def _decrypt_field(self, value: str | None) -> str | None:
        """Decrypt a field value if encryption is enabled."""
        if not value:
            return value
        enc = self._get_encryptor()
        if not enc:
            return value
        try:
            return enc.decrypt(value)
        except Exception:
            # Value may not be encrypted (pre-migration data)
            return value

    @property
    def session_factory(self):
        """Expose the session factory for shared use (e.g. QuotaService)."""
        return self._session_factory

    async def initialize(self) -> None:
        """Initialize database connections and vector backend."""
        self.config.ensure_dirs()

        # Relational database
        if self.config.store_backend == "postgres" and self.config.database_url:
            db_url = self.config.database_url
        else:
            db_url = f"sqlite+aiosqlite:///{self.config.db_path}"
        self._session_factory = await init_db(db_url)

        # Vector backend
        from sulci_core.backends import create_vector_backend

        self._vector_backend = await create_vector_backend(self.config, session_factory=self._session_factory)

        # Auto-migrate ChromaDB data if switching from chroma to local
        if self.config.store_backend == "local":
            chroma_path = self.config.chroma_path
            if chroma_path.exists() and any(chroma_path.iterdir()):
                try:
                    from sulci_core.backends.migrate_chroma import migrate_chroma_to_backend

                    count = await migrate_chroma_to_backend(chroma_path, self._vector_backend)
                    if count > 0:
                        logger.info("Auto-migrated %d vectors from ChromaDB", count)
                except Exception as e:
                    logger.warning("ChromaDB auto-migration failed: %s", e)

    # --- Knowledge Atoms ---

    async def add_atom(self, atom: KnowledgeAtom, user_id: str | None = None) -> KnowledgeAtom:
        """Store a knowledge atom in both SQLite and vector backend."""
        user_id = self._uid(user_id)
        # PII gate — check content, subject, object for PII
        from sulci_core.exceptions import PIIBlockedError
        from sulci_core.pii import PIIScanner

        scanner = PIIScanner()
        text_to_check = " ".join(t for t in [atom.content, atom.subject, atom.object] if t)
        pii_matches = scanner.scan(text_to_check)
        if pii_matches:
            # Check if atom belongs to a private project
            is_private = False
            for proj_name in atom.projects:
                proj = await self.get_project(proj_name)
                if proj and proj.project_type == ProjectType.PRIVATE:
                    is_private = True
                    break
            if not is_private:
                try:
                    from sulci_core.audit import get_audit_logger

                    audit = get_audit_logger(self.config.data_dir)
                    audit.log(
                        "pii_blocked",
                        pii_types=[m.pii_type for m in pii_matches],
                        projects=atom.projects,
                    )
                except Exception:
                    pass
                raise PIIBlockedError(
                    pii_matches=pii_matches,
                    content_preview=atom.content,
                )
            logger.warning("PII allowed in private project: %s", atom.projects)

        # Encrypt sensitive fields for storage
        stored_content = self._encrypt_field(atom.content)
        stored_subject = self._encrypt_field(atom.subject)
        stored_object = self._encrypt_field(atom.object)

        # SQLite + vector backend with rollback on failure
        async with self._session_factory() as session:
            row = KnowledgeAtomRow(
                id=atom.id,
                atom_type=atom.atom_type.value,
                content=stored_content,
                subject=stored_subject,
                predicate=atom.predicate,
                object=stored_object,
                confidence=atom.confidence,
                source_tool=atom.source_tool,
                tags_json=json.dumps(atom.tags),
                created_at=atom.created_at,
                updated_at=atom.updated_at,
                last_accessed_at=atom.last_accessed_at,
                access_count=atom.access_count,
                is_active=atom.is_active,
                verified_at=atom.verified_at,
                verification_status=atom.verification_status.value,
                projects_json=json.dumps(atom.projects),
                user_id=user_id,
            )
            session.add(row)
            await session.flush()

            # Vector backend — embed and upsert (use plaintext for embeddings)
            try:
                embedding = await self._embedder.embed(atom.content)
                metadata = {
                    "atom_type": atom.atom_type.value,
                    "subject": atom.subject or "",
                    "source_tool": atom.source_tool or "",
                    "confidence": atom.confidence,
                    "is_active": atom.is_active,
                    "tags": json.dumps(atom.tags),
                    "projects": json.dumps(atom.projects),
                }
                await self._vector_backend.upsert(atom.id, embedding, metadata, stored_content or atom.content)
            except Exception:
                await session.rollback()
                raise

            await session.commit()

        return atom

    async def add_atom_with_conflict_check(
        self, atom: KnowledgeAtom, user_id: str | None = None
    ) -> tuple[KnowledgeAtom, list[KnowledgeConflict]]:
        """Store atom and detect conflicts. Returns (atom, new_conflicts)."""
        user_id = self._uid(user_id)
        stored = await self.add_atom(atom, user_id=user_id)
        conflicts = await self.detect_conflicts(stored, user_id=user_id)
        return stored, conflicts

    async def get_atom(self, atom_id: str, user_id: str | None = None) -> KnowledgeAtom | None:
        """Retrieve a single atom by ID, optionally scoped to a user."""
        user_id = self._uid(user_id)
        async with self._session_factory() as session:
            row = await session.get(KnowledgeAtomRow, atom_id)
            if not row:
                return None
            if user_id is not None and row.user_id != user_id:
                return None
            return self._row_to_atom(row)

    async def find_atoms_by_source_and_subject(
        self,
        source_tool: str,
        subject: str,
        predicate: str | None = None,
        active_only: bool = True,
    ) -> list[KnowledgeAtom]:
        """Find atoms by source_tool + subject (deterministic, no embeddings needed).

        This is the reliable dedup path for integration syncs — it queries SQLite
        directly, so it works even when embeddings are unavailable.
        """
        async with self._session_factory() as session:
            query = select(KnowledgeAtomRow).where(
                KnowledgeAtomRow.source_tool == source_tool,
                func.lower(KnowledgeAtomRow.subject) == subject.lower(),
            )
            if predicate:
                query = query.where(KnowledgeAtomRow.predicate == predicate)
            if active_only:
                query = query.where(KnowledgeAtomRow.is_active.is_(True))
            query = query.order_by(KnowledgeAtomRow.updated_at.desc())
            result = await session.execute(query)
            rows = result.scalars().all()
            return [self._row_to_atom(r) for r in rows]

    async def update_atom(self, atom: KnowledgeAtom) -> KnowledgeAtom:
        """Update an existing atom."""
        atom.updated_at = datetime.now(UTC)

        stored_content = self._encrypt_field(atom.content)
        stored_subject = self._encrypt_field(atom.subject)
        stored_object = self._encrypt_field(atom.object)

        async with self._session_factory() as session:
            row = await session.get(KnowledgeAtomRow, atom.id)
            if not row:
                raise ValueError(f"Atom {atom.id} not found")
            row.atom_type = atom.atom_type.value
            row.content = stored_content
            row.subject = stored_subject
            row.predicate = atom.predicate
            row.object = stored_object
            row.confidence = atom.confidence
            row.source_tool = atom.source_tool
            row.tags_json = json.dumps(atom.tags)
            row.updated_at = atom.updated_at
            row.is_active = atom.is_active
            row.verified_at = atom.verified_at
            row.verification_status = atom.verification_status.value
            row.projects_json = json.dumps(atom.projects)
            await session.commit()

        # Update vector backend (use plaintext for embeddings)
        try:
            embedding = await self._embedder.embed(atom.content)
            metadata = {
                "atom_type": atom.atom_type.value,
                "subject": atom.subject or "",
                "source_tool": atom.source_tool or "",
                "confidence": atom.confidence,
                "is_active": atom.is_active,
                "tags": json.dumps(atom.tags),
                "projects": json.dumps(atom.projects),
            }
            await self._vector_backend.upsert(atom.id, embedding, metadata, stored_content or atom.content)
        except Exception:
            logger.warning("Vector upsert failed during update for atom %s", atom.id)

        return atom

    async def delete_atom(self, atom_id: str) -> bool:
        """Permanently delete an atom."""
        async with self._session_factory() as session:
            row = await session.get(KnowledgeAtomRow, atom_id)
            if not row:
                return False
            await session.delete(row)
            await session.commit()

        try:
            await self._vector_backend.delete(atom_id)
        except Exception:
            logger.warning("Vector delete failed for atom %s", atom_id)

        try:
            from sulci_core.audit import get_audit_logger

            audit = get_audit_logger(self.config.data_dir)
            audit.log("atom_delete", atom_id=atom_id)
        except Exception:
            pass

        return True

    async def delete_atoms_bulk(self, atom_ids: list[str]) -> int:
        """Delete multiple atoms by ID. Returns count of deleted atoms."""
        if not atom_ids:
            return 0

        deleted_count = 0
        async with self._session_factory() as session:
            for atom_id in atom_ids:
                row = await session.get(KnowledgeAtomRow, atom_id)
                if row:
                    await session.delete(row)
                    deleted_count += 1
            await session.commit()

        # Batch delete from vector backend
        if deleted_count > 0:
            try:
                await self._vector_backend.delete_batch(atom_ids)
            except Exception:
                logger.warning("Vector bulk delete failed for %d atoms", len(atom_ids))

            try:
                from sulci_core.audit import get_audit_logger

                audit = get_audit_logger(self.config.data_dir)
                audit.log("atoms_bulk_delete", count=deleted_count, atom_ids=atom_ids[:10])
            except Exception:
                pass

        return deleted_count

    async def delete_atoms_by_filter(
        self,
        source_tool: str | None = None,
        older_than_days: int | None = None,
        atom_type: str | None = None,
    ) -> int:
        """Delete atoms matching filter criteria. Requires at least one real filter."""
        if not source_tool and not older_than_days and not atom_type:
            raise ValueError("At least one filter (source_tool, older_than_days, atom_type) is required")

        async with self._session_factory() as session:
            query = select(KnowledgeAtomRow.id)
            if source_tool:
                query = query.where(KnowledgeAtomRow.source_tool == source_tool)
            if older_than_days is not None:
                cutoff = datetime.now(UTC) - timedelta(days=older_than_days)
                query = query.where(KnowledgeAtomRow.created_at < cutoff)
            if atom_type:
                query = query.where(KnowledgeAtomRow.atom_type == atom_type)

            result = await session.execute(query)
            matching_ids = [row[0] for row in result.all()]

        if not matching_ids:
            return 0

        return await self.delete_atoms_bulk(matching_ids)

    async def list_atoms(
        self,
        atom_type: AtomType | None = None,
        active_only: bool = True,
        limit: int = 50,
        offset: int = 0,
        project: str | None = None,
        exclude_local: bool = False,
        user_id: str | None = None,
    ) -> list[KnowledgeAtom]:
        """List atoms with optional filtering.

        project values:
          - None: return all atoms (no filtering)
          - "__global__": return only global atoms (empty projects list)
          - "<name>": return atoms scoped to that project + global atoms
        """
        user_id = self._uid(user_id)
        async with self._session_factory() as session:
            query = select(KnowledgeAtomRow)
            if atom_type:
                query = query.where(KnowledgeAtomRow.atom_type == atom_type.value)
            if active_only:
                query = query.where(KnowledgeAtomRow.is_active.is_(True))
            if user_id is not None:
                query = query.where(KnowledgeAtomRow.user_id == user_id)

            # Apply project filter at SQL level so LIMIT works correctly
            if project == "__global__":
                query = query.where(KnowledgeAtomRow.projects_json.in_(["[]", ""]))
            elif project:
                # Include atoms scoped to this project OR global atoms (empty projects list)
                from sqlalchemy import or_

                query = query.where(
                    or_(
                        KnowledgeAtomRow.projects_json.contains(f'"{project}"'),
                        KnowledgeAtomRow.projects_json.in_(["[]", ""]),
                    )
                )

            query = query.order_by(KnowledgeAtomRow.updated_at.desc())
            query = query.offset(offset).limit(limit)
            result = await session.execute(query)
            rows = result.scalars().all()
            atoms = [self._row_to_atom(r) for r in rows]

            if exclude_local:
                atoms = await self._filter_local_atoms(atoms)

            return atoms

    async def search_atoms(
        self,
        query: str,
        limit: int = 10,
        atom_type: AtomType | None = None,
        project: str | None = None,
        exclude_local: bool = False,
    ) -> list[ContextQueryResult]:
        """Hybrid search: vector similarity + SQLite text matching.

        Text matches found in vector results get a +0.15 relevance bonus.
        Text-only matches (not in vector results) get a 0.5 baseline score.
        Results are merged and re-sorted by score.
        """
        embedding = await self._embedder.embed(query)

        where_filter = {"is_active": True}
        if atom_type:
            where_filter["atom_type"] = atom_type.value

        # Over-fetch when filtering by project so we can post-filter
        fetch_limit = limit * 3 if project else limit

        try:
            results = await self._vector_backend.search(embedding, limit=fetch_limit, where=where_filter)
        except Exception as e:
            logger.warning("Vector search failed: %s", e)
            return []

        # Build vector results map: atom_id -> similarity
        vector_scores: dict[str, float] = {}
        for atom_id, distance in results:
            similarity = self._vector_backend.distance_to_similarity(distance)
            vector_scores[atom_id] = similarity

        # SQLite text search for keyword matches
        text_match_ids: set[str] = set()
        query_lower = query.lower().strip()
        if query_lower:
            async with self._session_factory() as session:
                from sqlalchemy import or_

                text_query = select(KnowledgeAtomRow.id).where(
                    KnowledgeAtomRow.is_active.is_(True),
                    or_(
                        func.lower(KnowledgeAtomRow.content).contains(query_lower),
                        func.lower(KnowledgeAtomRow.subject).contains(query_lower),
                        func.lower(KnowledgeAtomRow.object).contains(query_lower),
                    ),
                )
                if atom_type:
                    text_query = text_query.where(KnowledgeAtomRow.atom_type == atom_type.value)
                text_query = text_query.limit(fetch_limit)
                result = await session.execute(text_query)
                text_match_ids = {row[0] for row in result.all()}

        # Merge: combine vector scores with text match bonus
        all_candidate_ids = set(vector_scores.keys()) | text_match_ids
        scored_atoms: dict[str, float] = {}
        for atom_id in all_candidate_ids:
            vec_score = vector_scores.get(atom_id)
            is_text_match = atom_id in text_match_ids
            if vec_score is not None and is_text_match:
                scored_atoms[atom_id] = vec_score + 0.15
            elif vec_score is not None:
                scored_atoms[atom_id] = vec_score
            else:
                # Text-only match, not in vector results
                scored_atoms[atom_id] = 0.5

        # Sort by score descending
        sorted_ids = sorted(scored_atoms.keys(), key=lambda aid: scored_atoms[aid], reverse=True)

        query_results = []
        for atom_id in sorted_ids:
            atom = await self.get_atom(atom_id)
            if not atom:
                continue
            # Project filtering
            if project == "__global__" and atom.projects:
                continue
            if project and project != "__global__" and atom.projects and project not in atom.projects:
                continue

            score = min(scored_atoms[atom_id], 1.0)  # Cap at 1.0
            vec_sim = vector_scores.get(atom_id, 0.0)
            query_results.append(
                ContextQueryResult(
                    atom=atom,
                    relevance_score=score,
                    vector_similarity=vec_sim,
                )
            )
            if len(query_results) >= limit:
                break

        if exclude_local:
            filtered = []
            for qr in query_results:
                keep = True
                for proj_name in qr.atom.projects:
                    proj = await self.get_project(proj_name)
                    if proj and proj.data_mode == DataMode.LOCAL:
                        keep = False
                        break
                if keep:
                    filtered.append(qr)
            return filtered

        return query_results

    async def _filter_local_atoms(self, atoms: list[KnowledgeAtom]) -> list[KnowledgeAtom]:
        """Remove atoms that belong exclusively to local-mode projects."""
        filtered = []
        for atom in atoms:
            if not atom.projects:
                # Global atoms are always included
                filtered.append(atom)
                continue
            keep = True
            for proj_name in atom.projects:
                proj = await self.get_project(proj_name)
                if proj and proj.data_mode == DataMode.LOCAL:
                    keep = False
                    break
            if keep:
                filtered.append(atom)
        return filtered

    async def touch_atom(self, atom_id: str) -> None:
        """Update access tracking for an atom."""
        async with self._session_factory() as session:
            row = await session.get(KnowledgeAtomRow, atom_id)
            if row:
                row.last_accessed_at = datetime.now(UTC)
                row.access_count = (row.access_count or 0) + 1
                await session.commit()

    async def find_duplicates(self, threshold: float = 0.92, limit: int = 50) -> list[dict]:
        """Find near-duplicate atom pairs using vector similarity.

        Returns list of {atom_a, atom_b, similarity} dicts sorted by similarity desc.
        """
        max_scan = 500
        try:
            all_data = await self._vector_backend.get_all_with_embeddings(where={"is_active": True}, limit=max_scan)
        except Exception as e:
            logger.warning("Vector get_all failed in find_duplicates: %s", e)
            return []

        if not all_data["ids"] or all_data.get("embeddings") is None or len(all_data["embeddings"]) == 0:
            return []

        ids = all_data["ids"]
        embeddings = all_data["embeddings"]

        # For each atom, query its nearest neighbor (excluding itself)
        seen_pairs: set[tuple[str, str]] = set()
        duplicate_pairs: list[dict] = []

        for atom_id, embedding in zip(ids, embeddings, strict=False):
            try:
                results = await self._vector_backend.search(embedding, limit=5, where={"is_active": True})
            except Exception:
                logger.warning("Vector search failed for atom %s in find_duplicates, skipping", atom_id)
                continue

            for match_id, distance in results:
                if match_id == atom_id:
                    continue
                similarity = self._vector_backend.distance_to_similarity(distance)
                if similarity < threshold:
                    continue
                pair_key = tuple(sorted([atom_id, match_id]))
                if pair_key in seen_pairs:
                    continue
                seen_pairs.add(pair_key)

                atom_a = await self.get_atom(atom_id)
                atom_b = await self.get_atom(match_id)
                if atom_a and atom_b:
                    duplicate_pairs.append(
                        {
                            "atom_a": atom_a,
                            "atom_b": atom_b,
                            "similarity": round(similarity, 4),
                        }
                    )

            if len(duplicate_pairs) >= limit:
                break

        duplicate_pairs.sort(key=lambda x: x["similarity"], reverse=True)
        return duplicate_pairs[:limit]

    async def merge_atoms(self, keep_id: str, remove_id: str) -> KnowledgeAtom | None:
        """Merge two atoms: keep one, absorb tags/projects from the other, delete the other."""
        keep = await self.get_atom(keep_id)
        remove = await self.get_atom(remove_id)
        if not keep or not remove:
            return None

        # Absorb tags from the removed atom
        merged_tags = list(dict.fromkeys(keep.tags + remove.tags))
        keep.tags = merged_tags

        # Absorb projects from the removed atom
        merged_projects = list(dict.fromkeys(keep.projects + remove.projects))
        keep.projects = merged_projects

        # Take the higher confidence
        keep.confidence = max(keep.confidence, remove.confidence)

        # Combine access counts
        keep.access_count = (keep.access_count or 0) + (remove.access_count or 0)

        await self.update_atom(keep)
        await self.delete_atom(remove_id)
        return keep

    # --- Interactions ---

    async def add_interaction(self, interaction: Interaction) -> Interaction:
        """Store a captured interaction."""
        async with self._session_factory() as session:
            row = InteractionRow(
                id=interaction.id,
                source_tool=interaction.source_tool,
                messages_json=json.dumps(interaction.messages),
                extracted_atoms_json=json.dumps(interaction.extracted_atoms),
                created_at=interaction.created_at,
                metadata_json=json.dumps(interaction.metadata),
            )
            session.add(row)
            await session.commit()
        return interaction

    async def update_interaction_atoms(self, interaction_id: str, atom_ids: list[str]) -> None:
        """Update the extracted atom IDs for an interaction."""
        async with self._session_factory() as session:
            row = await session.get(InteractionRow, interaction_id)
            if row:
                row.extracted_atoms_json = json.dumps(atom_ids)
                await session.commit()

    # --- Context Injections ---

    async def log_injection(self, injection: ContextInjection) -> ContextInjection:
        """Log a context injection event."""
        async with self._session_factory() as session:
            row = ContextInjectionRow(
                id=injection.id,
                target_tool=injection.target_tool,
                query_text=injection.query_text,
                atoms_injected_json=json.dumps(injection.atoms_injected),
                injection_text=injection.injection_text,
                created_at=injection.created_at,
            )
            session.add(row)
            await session.commit()
        return injection

    async def list_injections(self, limit: int = 50) -> list[ContextInjection]:
        """List recent injection events."""
        async with self._session_factory() as session:
            query = select(ContextInjectionRow).order_by(ContextInjectionRow.created_at.desc()).limit(limit)
            result = await session.execute(query)
            rows = result.scalars().all()
            return [
                ContextInjection(
                    id=r.id,
                    target_tool=r.target_tool,
                    query_text=r.query_text,
                    atoms_injected=json.loads(r.atoms_injected_json),
                    injection_text=r.injection_text,
                    created_at=r.created_at,
                )
                for r in rows
            ]

    # --- Conflicts ---

    async def detect_conflicts(self, atom: KnowledgeAtom, user_id: str | None = None) -> list[KnowledgeConflict]:
        """Detect conflicts between a new atom and existing atoms.

        Two strategies:
        1. Same subject+predicate with different object value (with guards for
           integration sources, multi-valued predicates, and high-cardinality fields)
        2. High vector similarity (>0.92) with different content and subject awareness
        """
        conflicts: list[KnowledgeConflict] = []

        atom_source = (atom.source_tool or "").lower()
        atom_is_integration = atom_source in INTEGRATION_SOURCE_TOOLS

        # Strategy 1: same subject+predicate, different object
        if atom.subject and atom.predicate:
            # Skip if predicate is inherently multi-valued
            predicate_lower = atom.predicate.lower()
            is_multi_valued = any(kw in predicate_lower for kw in MULTI_VALUED_PREDICATE_KEYWORDS)

            if not is_multi_valued:
                async with self._session_factory() as session:
                    query = (
                        select(KnowledgeAtomRow)
                        .where(KnowledgeAtomRow.subject == atom.subject)
                        .where(KnowledgeAtomRow.predicate == atom.predicate)
                        .where(KnowledgeAtomRow.id != atom.id)
                        .where(KnowledgeAtomRow.is_active.is_(True))
                    )
                    result = await session.execute(query)
                    rows = result.scalars().all()

                # High cardinality (3+ atoms with same subject+predicate) = multi-valued by definition
                if len(rows) < 3:
                    for row in rows:
                        existing = self._row_to_atom(row)
                        if existing.object == atom.object:
                            continue
                        # Skip integration-to-integration conflicts (almost never real)
                        existing_source = (existing.source_tool or "").lower()
                        if atom_is_integration and existing_source in INTEGRATION_SOURCE_TOOLS:
                            continue

                        conflict = KnowledgeConflict(
                            atom_a_id=existing.id,
                            atom_b_id=atom.id,
                            conflict_type=ConflictType.CONTRADICTORY_VALUE,
                            description=(
                                f"'{atom.subject}.{atom.predicate}' has conflicting values: "
                                f"'{existing.object}' vs '{atom.object}'"
                            ),
                        )
                        await self.add_conflict(conflict)
                        conflicts.append(conflict)

        # Strategy 2: high vector similarity with different content
        try:
            search_results = await self.search_atoms(atom.content, limit=5)
            for result in search_results:
                if result.atom.id == atom.id:
                    continue
                if result.vector_similarity > 0.92 and result.atom.content != atom.content:
                    # Skip if both atoms are from integration sources
                    result_source = (result.atom.source_tool or "").lower()
                    if atom_is_integration and result_source in INTEGRATION_SOURCE_TOOLS:
                        continue
                    # Skip if both atoms have different subjects (different entities, not contradictions)
                    if atom.subject and result.atom.subject and atom.subject != result.atom.subject:
                        continue
                    # Check if already detected via strategy 1
                    already = any(c.atom_a_id == result.atom.id or c.atom_b_id == result.atom.id for c in conflicts)
                    if not already:
                        conflict = KnowledgeConflict(
                            atom_a_id=result.atom.id,
                            atom_b_id=atom.id,
                            conflict_type=ConflictType.SEMANTIC_CONTRADICTION,
                            description=(
                                f"Semantically similar but different content "
                                f"(similarity: {result.vector_similarity:.2f}): "
                                f"'{result.atom.content[:80]}' vs '{atom.content[:80]}'"
                            ),
                        )
                        await self.add_conflict(conflict)
                        conflicts.append(conflict)
        except Exception as e:
            logger.warning("Vector conflict detection failed: %s", e)

        return conflicts

    async def add_conflict(self, conflict: KnowledgeConflict, user_id: str | None = None) -> KnowledgeConflict:
        """Persist a conflict record."""
        async with self._session_factory() as session:
            row = KnowledgeConflictRow(
                id=conflict.id,
                atom_a_id=conflict.atom_a_id,
                atom_b_id=conflict.atom_b_id,
                conflict_type=conflict.conflict_type.value,
                description=conflict.description,
                status=conflict.status.value,
                resolved_winner_id=conflict.resolved_winner_id,
                created_at=conflict.created_at,
                resolved_at=conflict.resolved_at,
                user_id=user_id,
            )
            session.add(row)
            await session.commit()
        return conflict

    async def list_conflicts(
        self,
        status: ConflictStatus | None = None,
        limit: int = 50,
        user_id: str | None = None,
    ) -> list[KnowledgeConflict]:
        """Query conflicts with optional status filter."""
        async with self._session_factory() as session:
            query = select(KnowledgeConflictRow)
            if status:
                query = query.where(KnowledgeConflictRow.status == status.value)
            if user_id is not None:
                query = query.where(KnowledgeConflictRow.user_id == user_id)
            query = query.order_by(KnowledgeConflictRow.created_at.desc()).limit(limit)
            result = await session.execute(query)
            rows = result.scalars().all()
            return [self._row_to_conflict(r) for r in rows]

    async def resolve_conflict(self, conflict_id: str, winner_id: str) -> KnowledgeConflict | None:
        """Resolve conflict: keep winner, deactivate loser."""
        async with self._session_factory() as session:
            row = await session.get(KnowledgeConflictRow, conflict_id)
            if not row:
                return None

            row.status = ConflictStatus.RESOLVED.value
            row.resolved_winner_id = winner_id
            row.resolved_at = datetime.now(UTC)
            await session.commit()
            conflict = self._row_to_conflict(row)

        # Deactivate the loser
        loser_id = conflict.atom_a_id if winner_id == conflict.atom_b_id else conflict.atom_b_id
        loser = await self.get_atom(loser_id)
        if loser:
            loser.is_active = False
            await self.update_atom(loser)

        return conflict

    async def dismiss_conflict(self, conflict_id: str) -> KnowledgeConflict | None:
        """Dismiss conflict: keep both atoms active."""
        async with self._session_factory() as session:
            row = await session.get(KnowledgeConflictRow, conflict_id)
            if not row:
                return None

            row.status = ConflictStatus.DISMISSED.value
            row.resolved_at = datetime.now(UTC)
            await session.commit()
            return self._row_to_conflict(row)

    async def cleanup_orphaned_conflicts(self) -> int:
        """Dismiss open conflicts where one or both atoms no longer exist or are inactive."""
        open_conflicts = await self.list_conflicts(status=ConflictStatus.OPEN, limit=500)
        dismissed = 0
        for c in open_conflicts:
            atom_a = await self.get_atom(c.atom_a_id)
            atom_b = await self.get_atom(c.atom_b_id)
            if not atom_a or not atom_b or not atom_a.is_active or not atom_b.is_active:
                await self.dismiss_conflict(c.id)
                dismissed += 1
        return dismissed

    # --- Verification ---

    async def get_verification_queue(self, limit: int = 50) -> list[KnowledgeAtom]:
        """Get atoms needing verification: never verified or past interval."""
        interval_days = self.config.verification_interval_days
        cutoff = datetime.now(UTC).timestamp() - (interval_days * 86400)

        async with self._session_factory() as session:
            query = (
                select(KnowledgeAtomRow)
                .where(KnowledgeAtomRow.is_active.is_(True))
                .where(
                    (KnowledgeAtomRow.verified_at.is_(None))
                    | (KnowledgeAtomRow.verification_status == VerificationStatus.UNVERIFIED.value)
                )
                .order_by(KnowledgeAtomRow.created_at.asc())
                .limit(limit)
            )
            result = await session.execute(query)
            rows = result.scalars().all()

            # Also include atoms verified long ago
            if len(rows) < limit:
                query2 = (
                    select(KnowledgeAtomRow)
                    .where(KnowledgeAtomRow.is_active.is_(True))
                    .where(KnowledgeAtomRow.verification_status == VerificationStatus.VERIFIED.value)
                    .where(KnowledgeAtomRow.verified_at.isnot(None))
                    .order_by(KnowledgeAtomRow.verified_at.asc())
                    .limit(limit - len(rows))
                )
                result2 = await session.execute(query2)
                stale_rows = result2.scalars().all()
                # Filter by cutoff in Python (SQLite datetime comparison is tricky)
                for r in stale_rows:
                    if r.verified_at and r.verified_at.timestamp() < cutoff:
                        rows.append(r)

            return [self._row_to_atom(r) for r in rows]

    async def verify_atom(self, atom_id: str) -> KnowledgeAtom | None:
        """Mark atom as verified now."""
        atom = await self.get_atom(atom_id)
        if not atom:
            return None
        atom.verified_at = datetime.now(UTC)
        atom.verification_status = VerificationStatus.VERIFIED
        return await self.update_atom(atom)

    async def mark_atom_expired(self, atom_id: str) -> KnowledgeAtom | None:
        """Mark atom as expired/outdated and deactivate."""
        atom = await self.get_atom(atom_id)
        if not atom:
            return None
        atom.verification_status = VerificationStatus.EXPIRED
        atom.is_active = False
        return await self.update_atom(atom)

    # --- Projects ---

    async def list_projects(self, user_id: str | None = None) -> list[str]:
        """Get distinct project names across all active atoms (backward compat)."""
        user_id = self._uid(user_id)
        async with self._session_factory() as session:
            query = select(KnowledgeAtomRow.projects_json).where(KnowledgeAtomRow.is_active.is_(True))
            if user_id is not None:
                query = query.where(KnowledgeAtomRow.user_id == user_id)
            result = await session.execute(query)
            rows = result.scalars().all()

        projects: set[str] = set()
        for projects_json in rows:
            try:
                proj_list = json.loads(projects_json or "[]")
                projects.update(proj_list)
            except (json.JSONDecodeError, TypeError):
                pass
        return sorted(projects)

    # --- First-Class Projects ---

    async def create_project(self, project: Project, user_id: str | None = None) -> Project:
        """Create a new project entity."""
        async with self._session_factory() as session:
            row = ProjectRow(
                id=project.id,
                name=project.name,
                project_type=project.project_type.value,
                data_mode=project.data_mode.value,
                description=project.description,
                status=project.status.value,
                created_at=project.created_at,
                updated_at=project.updated_at,
                user_id=user_id,
            )
            session.add(row)
            await session.commit()
        return project

    async def get_project(self, name: str) -> Project | None:
        """Get a project by name."""
        async with self._session_factory() as session:
            query = select(ProjectRow).where(ProjectRow.name == name)
            result = await session.execute(query)
            row = result.scalar_one_or_none()
            if not row:
                return None
            return self._row_to_project(row)

    async def list_projects_full(self, user_id: str | None = None) -> list[Project]:
        """List all project entities."""
        user_id = self._uid(user_id)
        async with self._session_factory() as session:
            query = select(ProjectRow).order_by(ProjectRow.name)
            if user_id is not None:
                query = query.where(ProjectRow.user_id == user_id)
            result = await session.execute(query)
            rows = result.scalars().all()
            return [self._row_to_project(r) for r in rows]

    async def count_atoms_by_project(self, user_id: str | None = None) -> dict[str, int]:
        """Return {project_name: atom_count} for every project, plus '__global__' for unscoped atoms."""
        projects = await self.list_projects_full(user_id=user_id)
        counts: dict[str, int] = {}
        async with self._session_factory() as session:
            for proj in projects:
                q = (
                    select(func.count())
                    .select_from(KnowledgeAtomRow)
                    .where(
                        KnowledgeAtomRow.is_active.is_(True),
                        KnowledgeAtomRow.projects_json.contains(f'"{proj.name}"'),
                    )
                )
                if user_id is not None:
                    q = q.where(KnowledgeAtomRow.user_id == user_id)
                result = await session.execute(q)
                counts[proj.name] = result.scalar() or 0

            # Global atoms (empty projects list)
            q = (
                select(func.count())
                .select_from(KnowledgeAtomRow)
                .where(
                    KnowledgeAtomRow.is_active.is_(True),
                    KnowledgeAtomRow.projects_json.in_(["[]", ""]),
                )
            )
            if user_id is not None:
                q = q.where(KnowledgeAtomRow.user_id == user_id)
            result = await session.execute(q)
            counts["__global__"] = result.scalar() or 0
        return counts

    async def update_project(self, project: Project) -> Project:
        """Update an existing project."""
        project.updated_at = datetime.now(UTC)
        async with self._session_factory() as session:
            row = await session.execute(select(ProjectRow).where(ProjectRow.name == project.name))
            existing = row.scalar_one_or_none()
            if not existing:
                raise ValueError(f"Project '{project.name}' not found")
            existing.project_type = project.project_type.value
            existing.data_mode = project.data_mode.value
            existing.description = project.description
            existing.status = project.status.value
            existing.updated_at = project.updated_at
            await session.commit()
        return project

    async def rename_project(self, old_name: str, project: Project) -> Project:
        """Rename a project and update all atom references."""
        new_name = project.name
        project.updated_at = datetime.now(UTC)
        async with self._session_factory() as session:
            row = await session.execute(select(ProjectRow).where(ProjectRow.name == old_name))
            existing = row.scalar_one_or_none()
            if not existing:
                raise ValueError(f"Project '{old_name}' not found")

            # Update project row
            existing.name = new_name
            existing.project_type = project.project_type.value
            existing.data_mode = project.data_mode.value
            existing.description = project.description
            existing.status = project.status.value
            existing.updated_at = project.updated_at

            # Update all atoms that reference the old name
            atom_query = select(KnowledgeAtomRow).where(KnowledgeAtomRow.projects_json.contains(f'"{old_name}"'))
            atom_result = await session.execute(atom_query)
            for atom_row in atom_result.scalars().all():
                projects = json.loads(atom_row.projects_json or "[]")
                projects = [new_name if p == old_name else p for p in projects]
                atom_row.projects_json = json.dumps(projects)

            await session.commit()
        return project

    async def delete_project(self, name: str) -> bool:
        """Delete a project. Atoms are moved to global (their project tag is removed)."""
        async with self._session_factory() as session:
            row = await session.execute(select(ProjectRow).where(ProjectRow.name == name))
            existing = row.scalar_one_or_none()
            if not existing:
                return False

            # Remove project tag from all atoms that reference it
            atom_query = select(KnowledgeAtomRow).where(KnowledgeAtomRow.projects_json.contains(f'"{name}"'))
            atom_result = await session.execute(atom_query)
            for atom_row in atom_result.scalars().all():
                projects = json.loads(atom_row.projects_json or "[]")
                projects = [p for p in projects if p != name]
                atom_row.projects_json = json.dumps(projects)

            await session.delete(existing)
            await session.commit()
        return True

    # --- Dismissed Suggestions ---

    async def dismiss_suggestion(self, name: str) -> None:
        """Persist a dismissed suggestion so it doesn't reappear."""
        async with self._session_factory() as session:
            existing = await session.get(DismissedSuggestionRow, name)
            if not existing:
                session.add(DismissedSuggestionRow(name=name))
                await session.commit()

    async def list_dismissed_suggestions(self) -> set[str]:
        """Return all dismissed suggestion names."""
        async with self._session_factory() as session:
            result = await session.execute(select(DismissedSuggestionRow.name))
            return {row[0] for row in result.all()}

    async def move_atoms_to_project(self, atom_ids: list[str], target_project: str, source_project: str = "") -> int:
        """Move atoms into a target project, optionally removing from source. Returns count."""
        moved = 0
        async with self._session_factory() as session:
            for atom_id in atom_ids:
                row = await session.get(KnowledgeAtomRow, atom_id)
                if not row:
                    continue
                projects = json.loads(row.projects_json or "[]")
                changed = False
                if source_project and source_project in projects:
                    projects.remove(source_project)
                    changed = True
                if target_project not in projects:
                    projects.append(target_project)
                    changed = True
                if changed:
                    row.projects_json = json.dumps(projects)
                    moved += 1
            await session.commit()

        # Update vector backend metadata
        for atom_id in atom_ids:
            atom = await self.get_atom(atom_id)
            if atom:
                try:
                    metadata = {
                        "atom_type": atom.atom_type.value,
                        "subject": atom.subject or "",
                        "source_tool": atom.source_tool or "",
                        "confidence": atom.confidence,
                        "is_active": atom.is_active,
                        "tags": json.dumps(atom.tags),
                        "projects": json.dumps(atom.projects),
                    }
                    await self._vector_backend.update_metadata(atom.id, metadata)
                except Exception:
                    logger.warning("Vector metadata update failed for atom %s", atom_id)
        return moved

    # --- Stats ---

    async def get_stats(self, user_id: str | None = None) -> dict:
        """Get store statistics."""
        user_id = self._uid(user_id)
        async with self._session_factory() as session:

            def _add_user(q):
                return q.where(KnowledgeAtomRow.user_id == user_id) if user_id is not None else q

            atom_count = await session.scalar(_add_user(select(func.count()).select_from(KnowledgeAtomRow)))
            active_count = await session.scalar(
                _add_user(
                    select(func.count()).select_from(KnowledgeAtomRow).where(KnowledgeAtomRow.is_active.is_(True))
                )
            )
            interaction_q = select(func.count()).select_from(InteractionRow)
            if user_id is not None:
                interaction_q = interaction_q.where(InteractionRow.user_id == user_id)
            interaction_count = await session.scalar(interaction_q)

            injection_q = select(func.count()).select_from(ContextInjectionRow)
            if user_id is not None:
                injection_q = injection_q.where(ContextInjectionRow.user_id == user_id)
            injection_count = await session.scalar(injection_q)

            conflict_q = (
                select(func.count())
                .select_from(KnowledgeConflictRow)
                .where(KnowledgeConflictRow.status == ConflictStatus.OPEN.value)
            )
            if user_id is not None:
                conflict_q = conflict_q.where(KnowledgeConflictRow.user_id == user_id)
            open_conflicts = await session.scalar(conflict_q)

        projects = await self.list_projects(user_id=user_id)

        return {
            "total_atoms": atom_count or 0,
            "active_atoms": active_count or 0,
            "total_interactions": interaction_count or 0,
            "total_injections": injection_count or 0,
            "open_conflicts": open_conflicts or 0,
            "projects": projects,
        }

    async def get_session_brief(self, project: str | None = None, user_id: str | None = None) -> dict:
        """Get a session brief: recent knowledge, preferences, decisions for a project.

        Designed for Claude Code session startup — provides persistent context.
        """
        user_id = self._uid(user_id)
        # Get preferences and instructions globally (most live unscoped)
        preferences = await self.list_atoms(atom_type=AtomType.PREFERENCE, limit=10, user_id=user_id)
        instructions = await self.list_atoms(atom_type=AtomType.INSTRUCTION, limit=10, user_id=user_id)
        # Get recent decisions
        decisions = await self.list_atoms(atom_type=AtomType.DECISION, limit=5, project=project, user_id=user_id)
        # Get recent facts
        facts = await self.list_atoms(atom_type=AtomType.FACT, limit=5, project=project, user_id=user_id)
        # Get entities
        entities = await self.list_atoms(atom_type=AtomType.ENTITY, limit=5, project=project, user_id=user_id)
        # Get open conflicts
        conflicts = await self.list_conflicts(status=ConflictStatus.OPEN, limit=3, user_id=user_id)

        return {
            "project": project,
            "preferences": [a.content for a in preferences],
            "instructions": [a.content for a in instructions],
            "recent_decisions": [a.content for a in decisions],
            "recent_facts": [a.content for a in facts],
            "key_entities": [{"name": a.subject or a.content, "detail": a.content} for a in entities],
            "open_conflicts": len(conflicts),
            "summary": {
                "preferences_count": len(preferences),
                "instructions_count": len(instructions),
                "decisions_count": len(decisions),
                "facts_count": len(facts),
                "entities_count": len(entities),
            },
        }

    async def rebuild_vectors(self) -> int:
        """Rebuild the vector index from SQLite data."""
        await self._vector_backend.purge()

        # Re-index all active atoms
        atoms = await self.list_atoms(active_only=True, limit=10000)
        count = 0
        for atom in atoms:
            try:
                embedding = await self._embedder.embed(atom.content)
                metadata = {
                    "atom_type": atom.atom_type.value,
                    "subject": atom.subject or "",
                    "source_tool": atom.source_tool or "",
                    "confidence": atom.confidence,
                    "is_active": atom.is_active,
                    "tags": json.dumps(atom.tags),
                    "projects": json.dumps(atom.projects),
                }
                await self._vector_backend.upsert(atom.id, embedding, metadata, atom.content)
                count += 1
            except Exception as e:
                logger.warning("Failed to re-index atom %s: %s", atom.id, e)

        logger.info("Rebuilt vector index: %d atoms re-indexed", count)
        return count

    async def export_all(self) -> dict:
        """Export all data for privacy/portability."""
        atoms = await self.list_atoms(active_only=False, limit=10000)
        injections = await self.list_injections(limit=10000)
        return {
            "atoms": [a.model_dump(mode="json") for a in atoms],
            "injections": [i.model_dump(mode="json") for i in injections],
        }

    async def purge_all(self) -> dict:
        """Delete all data. Returns counts of deleted items."""
        stats = await self.get_stats()

        async with self._session_factory() as session:
            await session.execute(KnowledgeAtomRow.__table__.delete())
            await session.execute(InteractionRow.__table__.delete())
            await session.execute(ContextInjectionRow.__table__.delete())
            await session.commit()

        # Clear vector backend
        await self._vector_backend.purge()

        return {"deleted": stats}

    # --- Helpers ---

    def _row_to_atom(self, row: KnowledgeAtomRow) -> KnowledgeAtom:
        return KnowledgeAtom(
            id=row.id,
            atom_type=AtomType(row.atom_type),
            content=self._decrypt_field(row.content) or row.content,
            subject=self._decrypt_field(row.subject),
            predicate=row.predicate,
            object=self._decrypt_field(row.object),
            confidence=row.confidence,
            source_tool=row.source_tool,
            tags=json.loads(row.tags_json),
            created_at=row.created_at,
            updated_at=row.updated_at,
            last_accessed_at=row.last_accessed_at,
            access_count=row.access_count,
            is_active=row.is_active,
            verified_at=row.verified_at,
            verification_status=VerificationStatus(row.verification_status or "unverified"),
            projects=json.loads(row.projects_json) if row.projects_json else [],
        )

    @staticmethod
    def _row_to_project(row: ProjectRow) -> Project:
        return Project(
            id=row.id,
            name=row.name,
            project_type=ProjectType(row.project_type or "standard"),
            data_mode=DataMode(row.data_mode or "local"),
            description=row.description or "",
            status=ProjectStatus(row.status or "active"),
            created_at=row.created_at,
            updated_at=row.updated_at,
        )

    @staticmethod
    def _row_to_conflict(row: KnowledgeConflictRow) -> KnowledgeConflict:
        return KnowledgeConflict(
            id=row.id,
            atom_a_id=row.atom_a_id,
            atom_b_id=row.atom_b_id,
            conflict_type=ConflictType(row.conflict_type),
            description=row.description,
            status=ConflictStatus(row.status),
            resolved_winner_id=row.resolved_winner_id,
            created_at=row.created_at,
            resolved_at=row.resolved_at,
        )
