"""Inline message condenser -- compress verbose agent messages in-place.

Ported from BOSS smack_inline_condenser.  Non-destructive: original
message content is archived before replacement with a one-line summary.

Key design rules (from BOSS "CEO-locked" policy):
    - Never condense human messages
    - Never condense messages with unresponded @mentions
    - Only condense messages older than *min_age_seconds*
    - Only condense messages longer than *min_chars*
    - Archive originals for lazy-load expansion

Usage::

    from cohort.pipeline.inline_condenser import InlineCondenser

    condenser = InlineCondenser(
        archive_dir=Path("data/archives"),
        model="qwen2.5:1.5b",           # optional -- extractive fallback
    )

    # Sweep a batch of messages (typically from a channel)
    results = condenser.sweep(messages)
    # results: list of InlineResult(message_id=..., original_len=..., summary=...)

Each condensed message gets metadata:
    msg["metadata"]["condensed"] = True
    msg["metadata"]["condensed_summary"] = "one-line summary"
    msg["metadata"]["archive_ref"] = "archives/originals/{message_id}.json"
"""

from __future__ import annotations

import json
import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Configuration defaults
# ---------------------------------------------------------------------------

DEFAULT_MIN_AGE_SECONDS = 7200   # 2 hours
DEFAULT_MIN_CHARS = 500
DEFAULT_MAX_SUMMARY_TOKENS = 150
DEFAULT_BATCH_SIZE = 20

# Senders that are never condensed
PROTECTED_SENDERS: frozenset[str] = frozenset({
    "user", "human", "operator", "system",
})

# Channels where condensation is disabled
PROTECTED_CHANNELS: frozenset[str] = frozenset({
    "help", "human-oversight",
})


# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------

@dataclass
class InlineResult:
    """Outcome of condensing a single message."""

    message_id: str
    original_len: int
    summary: str
    archived: bool


# ---------------------------------------------------------------------------
# InlineCondenser
# ---------------------------------------------------------------------------

class InlineCondenser:
    """Non-destructive inline message condenser.

    Parameters
    ----------
    archive_dir:
        Where to persist original message content before replacing.
        Created on first write.
    model:
        Ollama model tag for LLM summarisation.  If ``None``, uses a
        simple extractive fallback (first + last sentence).
    ollama_url:
        Base URL for Ollama API.
    min_age_seconds:
        Messages younger than this are skipped.
    min_chars:
        Messages shorter than this are skipped.
    batch_size:
        Max messages to condense per sweep.
    """

    def __init__(
        self,
        archive_dir: Optional[Path] = None,
        model: Optional[str] = None,
        ollama_url: str = "http://localhost:11434",
        min_age_seconds: int = DEFAULT_MIN_AGE_SECONDS,
        min_chars: int = DEFAULT_MIN_CHARS,
        batch_size: int = DEFAULT_BATCH_SIZE,
    ) -> None:
        self.archive_dir = archive_dir
        self.model = model
        self.ollama_url = ollama_url.rstrip("/")
        self.min_age_seconds = min_age_seconds
        self.min_chars = min_chars
        self.batch_size = batch_size

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def sweep(
        self,
        messages: Sequence[Dict[str, Any]],
        channel_id: str = "",
        now: Optional[float] = None,
    ) -> List[InlineResult]:
        """Condense eligible messages in *messages*.

        Messages are **mutated in place** -- the caller is responsible for
        persisting the updated message list.

        Parameters
        ----------
        messages:
            Mutable list of message dicts.  Each must have ``id``,
            ``sender``, ``content``, and ``timestamp`` (ISO or epoch).
        channel_id:
            Used to check protected channels.
        now:
            Current epoch time.  Defaults to ``time.time()``.

        Returns
        -------
        list[InlineResult]
            One entry per condensed message.
        """
        if channel_id in PROTECTED_CHANNELS:
            return []

        now_ts = now or time.time()
        eligible = self._filter_eligible(messages, now_ts)
        results: List[InlineResult] = []

        for msg in eligible[: self.batch_size]:
            msg_id = msg.get("id", "")
            content = msg.get("content", "")

            # Archive original first (write-once safety)
            archived = self._archive(msg)

            # Generate summary
            summary = self._summarise(content)

            # Mutate message
            meta = msg.setdefault("metadata", {})
            meta["condensed"] = True
            meta["condensed_summary"] = summary
            if archived:
                meta["archive_ref"] = f"archives/originals/{msg_id}.json"

            msg["content"] = summary

            results.append(InlineResult(
                message_id=msg_id,
                original_len=len(content),
                summary=summary,
                archived=archived,
            ))

        if results:
            logger.info(
                "[inline-condenser] Condensed %d messages in channel=%s",
                len(results), channel_id,
            )

        return results

    def is_condensed(self, message: Dict[str, Any]) -> bool:
        """Check whether a message has already been condensed."""
        return message.get("metadata", {}).get("condensed", False) is True

    def get_original(self, message_id: str) -> Optional[str]:
        """Retrieve the archived original content for a condensed message."""
        if not self.archive_dir:
            return None
        path = self.archive_dir / "originals" / f"{message_id}.json"
        if not path.exists():
            return None
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            return data.get("content")
        except Exception:
            return None

    # ------------------------------------------------------------------
    # Eligibility filter
    # ------------------------------------------------------------------

    def _filter_eligible(
        self,
        messages: Sequence[Dict[str, Any]],
        now_ts: float,
    ) -> List[Dict[str, Any]]:
        """Return messages that qualify for condensation."""
        # Collect message IDs that have unresponded @mentions
        # (scan forward: if an @mention exists and no later message from
        #  the mentioned agent follows, it's unresponded)
        mentioned_ids: set = set()
        responded: set = set()
        for msg in messages:
            content = msg.get("content", "")
            sender = msg.get("sender", "")
            # Track responses to mentions
            if sender:
                responded.add(sender)
            # Find @mentions in content
            for m in re.findall(r"@(\w+)", content):
                if m not in responded:
                    mentioned_ids.add(msg.get("id", ""))

        eligible: List[Dict[str, Any]] = []
        for msg in messages:
            if not self._is_candidate(msg, now_ts, mentioned_ids):
                continue
            eligible.append(msg)
        return eligible

    def _is_candidate(
        self,
        msg: Dict[str, Any],
        now_ts: float,
        unresponded_mention_ids: set,
    ) -> bool:
        """Check individual message eligibility."""
        # Already condensed
        if self.is_condensed(msg):
            return False

        # Protected sender
        sender = msg.get("sender", "").lower()
        if sender in PROTECTED_SENDERS:
            return False

        # Too short
        content = msg.get("content", "")
        if len(content) < self.min_chars:
            return False

        # Too recent
        ts = msg.get("timestamp", "")
        msg_epoch = self._to_epoch(ts)
        if msg_epoch and (now_ts - msg_epoch) < self.min_age_seconds:
            return False

        # Has unresponded @mentions
        if msg.get("id") in unresponded_mention_ids:
            return False

        # Protected metadata flags
        meta = msg.get("metadata", {})
        if meta.get("sticky") or meta.get("undeletable"):
            return False

        return True

    # ------------------------------------------------------------------
    # Summarisation
    # ------------------------------------------------------------------

    def _summarise(self, content: str) -> str:
        """Generate a one-line summary of *content*."""
        if self.model:
            result = self._summarise_llm(content)
            if result:
                return result

        return self._summarise_extractive(content)

    def _summarise_llm(self, content: str) -> Optional[str]:
        """Use Ollama to generate a one-line summary."""
        try:
            import urllib.request

            payload = {
                "model": self.model,
                "messages": [
                    {
                        "role": "system",
                        "content": (
                            "Summarise the following message in ONE sentence. "
                            "Be factual and concise. Output only the summary, "
                            "no quotes or preamble."
                        ),
                    },
                    {"role": "user", "content": content[:2000]},
                ],
                "stream": False,
                "options": {
                    "num_predict": DEFAULT_MAX_SUMMARY_TOKENS,
                    "temperature": 0.1,
                },
            }
            req = urllib.request.Request(
                f"{self.ollama_url}/api/chat",
                data=json.dumps(payload).encode(),
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=5.0) as resp:
                body = json.loads(resp.read())

            summary = body.get("message", {}).get("content", "").strip()
            if summary and len(summary) < len(content):
                return summary
        except Exception as exc:
            logger.debug("[inline-condenser] LLM summary failed: %s", exc)
        return None

    def _summarise_extractive(self, content: str) -> str:
        """First-sentence + last-sentence extractive summary."""
        sentences = re.split(r"(?<=[.!?])\s+", content.strip())
        sentences = [s.strip() for s in sentences if s.strip()]
        if not sentences:
            return content[:150] + "..."
        if len(sentences) == 1:
            return sentences[0][:200]
        first = sentences[0][:150]
        last = sentences[-1][:150]
        if first == last:
            return first
        return f"{first} [...] {last}"

    # ------------------------------------------------------------------
    # Archival
    # ------------------------------------------------------------------

    def _archive(self, msg: Dict[str, Any]) -> bool:
        """Archive original content to disk.  Write-once: skip if exists."""
        if not self.archive_dir:
            return False

        msg_id = msg.get("id", "")
        if not msg_id:
            return False

        originals_dir = self.archive_dir / "originals"
        originals_dir.mkdir(parents=True, exist_ok=True)
        path = originals_dir / f"{msg_id}.json"

        if path.exists():
            return True  # already archived

        try:
            archive_data = {
                "id": msg_id,
                "sender": msg.get("sender", ""),
                "content": msg.get("content", ""),
                "timestamp": msg.get("timestamp", ""),
                "archived_at": time.time(),
            }
            path.write_text(json.dumps(archive_data, indent=2), encoding="utf-8")
            return True
        except Exception as exc:
            logger.warning("[inline-condenser] Archive failed for %s: %s", msg_id, exc)
            return False

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _to_epoch(ts: Any) -> Optional[float]:
        """Convert timestamp to epoch seconds.  Returns None on failure."""
        if isinstance(ts, (int, float)):
            return float(ts)
        if isinstance(ts, str) and ts:
            try:
                dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
                return dt.timestamp()
            except (ValueError, TypeError):
                pass
        return None
