"""
Memory curator: reads daily logs, extracts durable facts, and maintains MEMORY.md.

The curator is the only writer of MEMORY.md. Agents write to the daily log
and directly to the vector DB (for immediate recall). The curator's job is
to distill logs into high-quality, deduplicated long-term facts with
accurate importance scores.
"""

from typing import List, Dict, Any, Optional
from datetime import datetime, date, timedelta
from dataclasses import dataclass
import json
import re
from .metadata import MemoryMetadata
from .text_utils import clean_memory_content


SUPERSEDE_PROMPT = """Two memory entries are compared below. Determine if the NEW FACT should replace the EXISTING MEMORY, or if both should be kept.

EXISTING MEMORY: {existing}

NEW FACT: {new_fact}

A new fact REPLACES when it:
- Resolves or closes an issue mentioned in the existing memory
- Corrects or supersedes incorrect or outdated information
- Represents a later status update (e.g., "completed" replaces "in progress")
- Makes the existing memory misleading to keep

A new fact should be KEPT ALONGSIDE (ADD) when it:
- Adds different or complementary details
- Is about a related but distinct event or timeframe
- Does not contradict the existing memory

Respond with JSON only: {{"action": "REPLACE", "reasoning": "brief explanation"}} or {{"action": "ADD", "reasoning": "brief explanation"}}"""


CURATION_PROMPT = """You are analyzing interaction logs to extract facts worth preserving in long-term memory.

**INCLUDE facts that are:**
- Decisions, preferences, or goals ("decided to...", "prefers...", "goal is...")
- Key domain knowledge, definitions, or discoveries
- Important relationships or contacts
- Project status, deadlines, or commitments
- Recurring patterns or important insights
- Critical action items or security/risk issues

**EXCLUDE:**
- Routine queries with no lasting value
- Time-sensitive information that will be stale within days (e.g. "meeting in 2 hours")
- Small talk or greetings
- Information already captured elsewhere

**IMPORTANCE SCALE:**
- 0.9+: Critical (security issues, major decisions, hard deadlines)
- 0.7-0.8: Important (key facts, significant events, action items)
- 0.5-0.6: Useful (general context, preferences, background info)
- 0.3-0.4: Low priority (minor notes, speculative info)

**OUTPUT FORMAT** (JSON only, no other text):
```json
{
  "facts": [
    {
      "content": "Clear, self-contained statement of the fact",
      "category": "security|decision|knowledge|contact|project|event|preference",
      "importance": 0.0-1.0,
      "reasoning": "Why this is worth remembering"
    }
  ]
}
```

**LOGS TO ANALYZE:**
{logs}

Extract facts as JSON:"""


@dataclass
class CurationResult:
    success: bool
    facts_extracted: int = 0
    facts_added: int = 0
    memories_updated: int = 0
    memories_pruned: int = 0
    existing_memories: int = 0   # P1-C: count of memories already stored by agent
    memory_md_regenerated: bool = False
    tokens_used: int = 0
    cost_usd: float = 0.0
    error: Optional[str] = None
    timestamp: datetime = None


class CloudMemoryCurator:
    """
    Distills daily interaction logs into durable, well-scored long-term memories.

    Flow:
    1. Read recent daily logs
    2. Extract facts with importance scores via LLM
    3. Deduplicate against existing vector DB
    4. Store new facts directly into vector DB
    5. Regenerate clean MEMORY.md from vector DB
    6. Prune low-importance old memories
    """

    def __init__(
        self,
        backend,
        agent_id: str,
        llm_provider: Optional[str] = None,
        llm_model: Optional[str] = None,
        api_key: Optional[str] = None
    ):
        self.backend = backend
        self.agent_id = agent_id
        self.llm_provider = llm_provider or "openai"
        self.llm_model = llm_model or "gpt-4o-mini"

        from ...config.settings import settings
        from ...llm.factory import create_llm_provider

        key = api_key or settings.get_llm_api_key(self.llm_provider)
        self.llm = create_llm_provider(
            provider=self.llm_provider,
            model=self.llm_model,
            api_key=key,
            agent_id=f"{agent_id}_curator"
        )

    async def curate(self, days_to_process: int = 1, max_tokens: int = 4000) -> CurationResult:
        """
        Run curation: extract facts from logs, store/update in vector DB, regenerate MEMORY.md.
        """
        logs = await self._read_recent_logs(days_to_process)

        # P1-C: count memories already stored by the agent (before curation adds more)
        existing_memories = await self._count_existing_memories()

        facts = []
        if logs:
            try:
                facts = await self._extract_facts(logs, max_tokens)
            except Exception as e:
                return CurationResult(success=False, error=f"Extraction failed: {e}")

        new_facts, updates = await self._classify_facts(facts) if facts else ([], [])
        added_count = await self._store_facts(new_facts)
        updated_count = await self._apply_updates(updates)

        # Regenerate MEMORY.md from vector DB (clean snapshot, always current)
        await self.backend.regenerate_memory_md()

        pruned_count = await self._prune_old_memories()

        # P2-1: enforce size cap on local backend after pruning
        if hasattr(self.backend, '_enforce_size_limit'):
            await self.backend._enforce_size_limit()

        token_usage = self.llm._get_last_token_usage()
        total_tokens = token_usage.get('total_tokens', 0)
        cost = self.llm._estimate_cost(token_usage) if total_tokens > 0 else 0.0

        return CurationResult(
            success=True,
            facts_extracted=len(facts),
            facts_added=added_count,
            memories_updated=updated_count,
            memories_pruned=pruned_count,
            existing_memories=existing_memories,
            memory_md_regenerated=True,
            tokens_used=total_tokens,
            cost_usd=cost or 0.0,
            timestamp=datetime.now()
        )

    async def _read_recent_logs(self, days: int) -> str:
        all_logs = []
        for i in range(days):
            log_date = date.today() - timedelta(days=i)
            log_path = f"logs/{log_date.isoformat()}.md"
            try:
                content = await self.backend.storage.read_file(
                    str(self.backend.logs_dir / f"{log_date.isoformat()}.md")
                )
                if content and not content.startswith("File not found"):
                    all_logs.append(f"# {log_date}\n{content}")
            except Exception:
                continue
        return "\n\n".join(all_logs)

    async def _count_existing_memories(self) -> int:
        """Count memories currently stored in the backend (for P1-C status display)."""
        try:
            if hasattr(self.backend, 'vector_db'):
                import sqlite3
                conn = sqlite3.connect(str(self.backend.vector_db))
                cursor = conn.cursor()
                cursor.execute("SELECT COUNT(*) FROM chunks")
                count = cursor.fetchone()[0]
                conn.close()
                return count
            elif hasattr(self.backend, '_get_client'):
                client = await self.backend._get_client()
                result = await (
                    client.table("daita_org_memory")
                    .select("id", count="exact")
                    .eq("org_id", self.backend.org_id)
                    .eq("workspace", self.backend.workspace)
                    .is_("deleted_at", "null")
                    .execute()
                )
                return result.count or 0
            elif hasattr(self.backend, 'vectors') and self.backend._vectors_loaded:
                return len(self.backend.vectors.get('vectors', {}))
        except Exception:
            pass
        return 0

    async def _extract_facts(self, logs: str, max_tokens: int) -> List[Dict]:
        prompt = CURATION_PROMPT.replace("{logs}", logs)
        response = await self.llm.generate(
            messages=prompt,
            max_tokens=max_tokens,
            temperature=0.3
        )

        try:
            data = json.loads(response)
            return data.get("facts", [])
        except json.JSONDecodeError:
            json_match = re.search(r'```json\n(.*?)\n```', response, re.DOTALL)
            if json_match:
                data = json.loads(json_match.group(1))
                return data.get("facts", [])
            return []

    async def _classify_facts(
        self,
        facts: List[Dict]
    ) -> tuple:
        """
        Classify extracted facts into new additions and superseding updates.

        Uses raw semantic similarity (from score_breakdown when available) to avoid
        the importance boost inflating near-matches into false "true duplicate" signals.

        Thresholds (on raw semantic similarity):
        - similarity < 0.7: no near match — store as new fact
        - similarity 0.7-0.9: gray zone — LLM decides REPLACE or ADD
        - similarity >= 0.9: near-identical text — true duplicate, skip

        Returns:
            (new_facts, updates) where updates is list of (new_fact, old_chunk_id)
        """
        new_facts = []
        updates = []

        for fact in facts:
            matches = await self.backend.recall(
                query=fact["content"],
                limit=1,
                score_threshold=0.6  # Low threshold — we threshold on raw similarity below
            )

            if not matches:
                new_facts.append(fact)
                continue

            best = matches[0]

            # Prefer raw semantic similarity to avoid importance-boost distortion.
            # All backends now include raw_semantic_score; score_breakdown is a fallback.
            raw_similarity = (
                best.get('raw_semantic_score')
                or best.get('score_breakdown', {}).get('semantic')
                or best['score']
            )

            if raw_similarity < 0.7:
                # Not close enough to be related — add as new
                new_facts.append(fact)
                continue

            if raw_similarity >= 0.9:
                # Near-identical wording — true duplicate, skip
                continue

            # Gray zone (0.7-0.9): ask LLM whether this supersedes the existing memory.
            # If REPLACE: delete old chunk and store the update.
            # If ADD: this is a related-but-not-superseding near-match — skip it.
            #   (Genuinely new facts score < 0.7 and never reach this branch.)
            action = await self._check_supersedes(fact['content'], best['content'])
            if action == 'REPLACE':
                updates.append((fact, best['chunk_id']))

        return new_facts, updates

    async def _check_supersedes(self, new_fact: str, existing: str) -> str:
        """
        Ask the LLM whether a new fact replaces or adds alongside an existing memory.

        Returns 'REPLACE' or 'ADD'.
        """
        prompt = SUPERSEDE_PROMPT.format(existing=existing, new_fact=new_fact)
        try:
            response = await self.llm.generate(
                messages=prompt,
                max_tokens=100,
                temperature=0.0
            )
            data = json.loads(response)
            return data.get('action', 'ADD').upper()
        except Exception:
            # On any failure, default to ADD (safe — avoids accidental data loss)
            return 'ADD'

    async def _apply_updates(self, updates: List[tuple]) -> int:
        """
        Apply superseding updates: delete old chunk, store new content.

        Args:
            updates: list of (new_fact_dict, old_chunk_id)

        Returns:
            Number of memories replaced
        """
        if not updates:
            return 0

        for fact, old_chunk_id in updates:
            # Delete the stale memory from the backend
            await self._delete_chunk(old_chunk_id)

            # Store the updated fact
            cleaned = clean_memory_content(fact['content'])
            metadata = MemoryMetadata(
                content=cleaned,
                importance=fact['importance'],
                source='curation',
                category=fact.get('category'),
                created_at=datetime.now(),
                pinned=False
            )
            await self.backend.search.store_chunk(cleaned, metadata)

        return len(updates)

    async def _delete_chunk(self, chunk_id: str):
        """Delete a single chunk from the backend."""
        if hasattr(self.backend, 'delete_chunks'):
            # All current backends implement delete_chunks
            await self.backend.delete_chunks([chunk_id])
        elif hasattr(self.backend, 'vector_db'):
            import sqlite3
            conn = sqlite3.connect(str(self.backend.vector_db))
            cursor = conn.cursor()
            cursor.execute("DELETE FROM embeddings WHERE chunk_id = ?", (chunk_id,))
            cursor.execute("DELETE FROM chunks WHERE chunk_id = ?", (chunk_id,))
            conn.commit()
            conn.close()
        else:
            # S3 backend legacy fallback: remove from in-memory vectors
            if self.backend._vectors_loaded:
                self.backend.vectors.get('vectors', {}).pop(chunk_id, None)
                self.backend._vectors_modified = True

    async def _store_facts(self, facts: List[Dict]) -> int:
        """Store curated facts directly into the vector DB with proper importance."""
        for fact in facts:
            cleaned = clean_memory_content(fact['content'])
            metadata = MemoryMetadata(
                content=cleaned,
                importance=fact['importance'],
                source='curation',
                category=fact.get('category'),
                created_at=datetime.now(),
                pinned=False
            )
            await self.backend.search.store_chunk(cleaned, metadata)
        return len(facts)

    async def _prune_old_memories(self) -> int:
        """Prune low-importance old memories. Handles local SQLite, S3, and Supabase backends."""
        if hasattr(self.backend, 'vector_db'):
            return self._prune_local_backend()
        elif hasattr(self.backend, '_get_client'):
            # Supabase backend
            return await self._prune_supabase_backend()
        else:
            return self._prune_s3_backend()

    def _prune_local_backend(self) -> int:
        """Prune from local SQLite backend."""
        import sqlite3

        db_path = self.backend.vector_db
        conn = sqlite3.connect(str(db_path))
        cursor = conn.cursor()

        cursor.execute("SELECT chunk_id, metadata FROM chunks WHERE metadata IS NOT NULL")
        chunks_to_prune = []

        for chunk_id, metadata_json in cursor.fetchall():
            if not metadata_json:
                continue
            try:
                metadata = MemoryMetadata.from_dict(json.loads(metadata_json))
                if metadata.should_prune(max_age_days=90, min_importance_threshold=0.3):
                    chunks_to_prune.append(chunk_id)
            except Exception:
                continue

        if chunks_to_prune:
            placeholders = ','.join('?' * len(chunks_to_prune))
            cursor.execute(f"DELETE FROM embeddings WHERE chunk_id IN ({placeholders})", chunks_to_prune)
            cursor.execute(f"DELETE FROM chunks WHERE chunk_id IN ({placeholders})", chunks_to_prune)

        conn.commit()
        conn.close()
        return len(chunks_to_prune)

    def _prune_s3_backend(self) -> int:
        """Prune from S3 backend (in-memory vectors)."""
        if not self.backend._vectors_loaded or not self.backend.vectors:
            return 0

        chunks_to_prune = []
        for chunk_id, vec_data in self.backend.vectors.get('vectors', {}).items():
            meta_dict = vec_data.get('metadata', {})
            if not meta_dict:
                continue
            try:
                metadata = MemoryMetadata.from_dict(meta_dict)
                if metadata.should_prune(max_age_days=90, min_importance_threshold=0.3):
                    chunks_to_prune.append(chunk_id)
            except Exception:
                continue

        for chunk_id in chunks_to_prune:
            self.backend.vectors['vectors'].pop(chunk_id, None)

        if chunks_to_prune:
            self.backend._vectors_modified = True
            self.backend._pending_memory_count_decrement += len(chunks_to_prune)

        return len(chunks_to_prune)

    async def _prune_supabase_backend(self) -> int:
        """Prune low-importance old memories from Supabase via soft delete."""
        try:
            client = await self.backend._get_client()
            result = await (
                client.table("daita_org_memory")
                .select("id, importance, pinned, recall_count, created_at")
                .eq("org_id", self.backend.org_id)
                .eq("workspace", self.backend.workspace)
                .is_("deleted_at", "null")
                .eq("authoritative", False)
                .execute()
            )
            rows = result.data or []

            to_prune = []
            for row in rows:
                if row.get("pinned", False):
                    continue
                importance = float(row.get("importance", 0.5))
                access_count = int(row.get("recall_count", 0))
                created_at_str = row.get("created_at", "")
                if not created_at_str:
                    continue
                try:
                    from datetime import timezone as _tz
                    created_at = datetime.fromisoformat(
                        str(created_at_str).replace("Z", "+00:00")
                    )
                    age_days = (datetime.now(_tz.utc) - created_at).days
                except Exception:
                    continue

                should_prune = (
                    (access_count == 0 and age_days > 60 and importance < 0.5)
                    or (age_days > 90 and importance < 0.3)
                )
                if should_prune:
                    to_prune.append(row["id"])

            if to_prune:
                await self.backend._soft_delete(to_prune)

            return len(to_prune)
        except Exception as e:
            import logging as _logging
            _logging.getLogger(__name__).warning("Supabase pruning failed: %s", e)
            return 0
