"""ThinkingEngine — Timmy's always-on inner thought thread."""

import logging
import uuid
from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path

from config import settings
from timmy.thinking._db import _DEFAULT_DB, Thought, _get_conn, _row_to_thought
from timmy.thinking._distillation import _DistillationMixin
from timmy.thinking._issue_filing import _IssueFilingMixin
from timmy.thinking._seeds_mixin import _SeedsMixin
from timmy.thinking._snapshot import _SnapshotMixin
from timmy.thinking.seeds import _THINK_TAG_RE, _THINKING_PROMPT

logger = logging.getLogger(__name__)


class ThinkingEngine(_DistillationMixin, _IssueFilingMixin, _SnapshotMixin, _SeedsMixin):
    """Timmy's background thinking engine — always pondering."""

    # Maximum retries when a generated thought is too similar to recent ones
    _MAX_DEDUP_RETRIES = 2
    # Similarity threshold (0.0 = completely different, 1.0 = identical)
    _SIMILARITY_THRESHOLD = 0.6

    def __init__(self, db_path: Path = _DEFAULT_DB) -> None:
        self._db_path = db_path
        self._last_thought_id: str | None = None
        self._last_input_time: datetime = datetime.now(UTC)

        # Load the most recent thought for chain continuity
        try:
            latest = self.get_recent_thoughts(limit=1)
            if latest:
                self._last_thought_id = latest[0].id
        except Exception as exc:
            logger.debug("Failed to load recent thought: %s", exc)
            pass  # Fresh start if DB doesn't exist yet

    def record_user_input(self) -> None:
        """Record that a user interaction occurred, resetting the idle timer."""
        self._last_input_time = datetime.now(UTC)

    def _is_idle(self) -> bool:
        """Return True if no user input has occurred within the idle timeout."""
        timeout = settings.thinking_idle_timeout_minutes
        if timeout <= 0:
            return False  # Disabled — never idle
        return datetime.now(UTC) - self._last_input_time > timedelta(minutes=timeout)

    def _build_thinking_context(self) -> tuple[str, str, list[Thought]]:
        """Assemble the context needed for a thinking cycle.

        Returns:
            (memory_context, system_context, recent_thoughts)
        """
        memory_context = self._load_memory_context()
        system_context = self._gather_system_snapshot()
        recent_thoughts = self.get_recent_thoughts(limit=5)
        return memory_context, system_context, recent_thoughts

    async def _generate_novel_thought(
        self,
        prompt: str | None,
        memory_context: str,
        system_context: str,
        recent_thoughts: list[Thought],
    ) -> tuple[str | None, str]:
        """Run the dedup-retry loop to produce a novel thought.

        Returns:
            (content, seed_type) — content is None if no novel thought produced.
        """
        seed_type: str = "freeform"

        for attempt in range(self._MAX_DEDUP_RETRIES + 1):
            if prompt:
                seed_type = "prompted"
                seed_context = f"Journal prompt: {prompt}"
            else:
                seed_type, seed_context = self._gather_seed()

            continuity = self._build_continuity_context()

            full_prompt = _THINKING_PROMPT.format(
                memory_context=memory_context,
                system_context=system_context,
                seed_context=seed_context,
                continuity_context=continuity,
            )

            try:
                raw = await self._call_agent(full_prompt)
            except Exception as exc:
                logger.warning("Thinking cycle failed (Ollama likely down): %s", exc)
                return None, seed_type

            if not raw or not raw.strip():
                logger.debug("Thinking cycle produced empty response, skipping")
                return None, seed_type

            content = raw.strip()

            # Dedup: reject thoughts too similar to recent ones
            if not self._is_too_similar(content, recent_thoughts):
                return content, seed_type  # Good — novel thought

            if attempt < self._MAX_DEDUP_RETRIES:
                logger.info(
                    "Thought too similar to recent (attempt %d/%d), retrying with new seed",
                    attempt + 1,
                    self._MAX_DEDUP_RETRIES + 1,
                )
            else:
                logger.warning(
                    "Thought still repetitive after %d retries, discarding",
                    self._MAX_DEDUP_RETRIES + 1,
                )
                return None, seed_type

        return None, seed_type

    async def _process_thinking_result(self, thought: Thought) -> None:
        """Run all post-hooks after a thought is stored."""
        self._maybe_check_memory()
        await self._maybe_distill()
        await self._maybe_file_issues()
        await self._check_workspace()
        self._maybe_check_memory_status()
        self._update_memory(thought)
        self._log_event(thought)
        self._write_journal(thought)
        await self._broadcast(thought)

    async def think_once(self, prompt: str | None = None) -> Thought | None:
        """Execute one thinking cycle.

        Args:
            prompt: Optional custom seed prompt. When provided, overrides
                    the random seed selection and uses "prompted" as the
                    seed type — useful for journal prompts from the CLI.

        1. Gather a seed context (or use the custom prompt)
        2. Build a prompt with continuity from recent thoughts
        3. Call the agent
        4. Store the thought
        5. Log the event and broadcast via WebSocket
        """
        if not settings.thinking_enabled:
            return None

        # Skip idle periods — don't count internal processing as thoughts
        if not prompt and self._is_idle():
            logger.debug(
                "Thinking paused — no user input for %d minutes",
                settings.thinking_idle_timeout_minutes,
            )
            return None

        # Capture arrival time *before* the LLM call so the thought
        # timestamp reflects when the cycle started, not when the
        # (potentially slow) generation finished.  Fixes #582.
        arrived_at = datetime.now(UTC).isoformat()

        memory_context, system_context, recent_thoughts = self._build_thinking_context()

        content, seed_type = await self._generate_novel_thought(
            prompt,
            memory_context,
            system_context,
            recent_thoughts,
        )
        if not content:
            return None

        thought = self._store_thought(content, seed_type, arrived_at=arrived_at)
        self._last_thought_id = thought.id

        await self._process_thinking_result(thought)

        logger.info(
            "Thought [%s] (%s): %s",
            thought.id[:8],
            seed_type,
            thought.content[:80],
        )
        return thought

    def get_recent_thoughts(self, limit: int = 20) -> list[Thought]:
        """Retrieve the most recent thoughts."""
        with _get_conn(self._db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM thoughts ORDER BY created_at DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [_row_to_thought(r) for r in rows]

    def get_thought(self, thought_id: str) -> Thought | None:
        """Retrieve a single thought by ID."""
        with _get_conn(self._db_path) as conn:
            row = conn.execute("SELECT * FROM thoughts WHERE id = ?", (thought_id,)).fetchone()
        return _row_to_thought(row) if row else None

    def get_thought_chain(self, thought_id: str, max_depth: int = 20) -> list[Thought]:
        """Follow the parent chain backward from a thought.

        Returns thoughts in chronological order (oldest first).
        """
        chain = []
        current_id: str | None = thought_id

        with _get_conn(self._db_path) as conn:
            for _ in range(max_depth):
                if not current_id:
                    break
                row = conn.execute("SELECT * FROM thoughts WHERE id = ?", (current_id,)).fetchone()
                if not row:
                    break
                chain.append(_row_to_thought(row))
                current_id = row["parent_id"]

        chain.reverse()  # Chronological order
        return chain

    def count_thoughts(self) -> int:
        """Return total number of stored thoughts."""
        with _get_conn(self._db_path) as conn:
            count = conn.execute("SELECT COUNT(*) as c FROM thoughts").fetchone()["c"]
        return count

    def prune_old_thoughts(self, keep_days: int = 90, keep_min: int = 200) -> int:
        """Delete thoughts older than *keep_days*, always retaining at least *keep_min*.

        Returns the number of deleted rows.
        """
        with _get_conn(self._db_path) as conn:
            try:
                total = conn.execute("SELECT COUNT(*) as c FROM thoughts").fetchone()["c"]
                if total <= keep_min:
                    return 0
                cutoff = (datetime.now(UTC) - timedelta(days=keep_days)).isoformat()
                cursor = conn.execute(
                    "DELETE FROM thoughts WHERE created_at < ? AND id NOT IN "
                    "(SELECT id FROM thoughts ORDER BY created_at DESC LIMIT ?)",
                    (cutoff, keep_min),
                )
                deleted = cursor.rowcount
                conn.commit()
                return deleted
            except Exception as exc:
                logger.warning("Thought pruning failed: %s", exc)
                return 0

    # ── Deduplication ────────────────────────────────────────────────────

    def _is_too_similar(self, candidate: str, recent: list[Thought]) -> bool:
        """Check if *candidate* is semantically too close to any recent thought.

        Uses SequenceMatcher on normalised text (lowered, stripped) for a fast
        approximation of semantic similarity that works without external deps.
        """
        norm_candidate = candidate.lower().strip()
        for thought in recent:
            norm_existing = thought.content.lower().strip()
            ratio = SequenceMatcher(None, norm_candidate, norm_existing).ratio()
            if ratio >= self._SIMILARITY_THRESHOLD:
                logger.debug(
                    "Thought rejected (%.0f%% similar to %s): %.60s",
                    ratio * 100,
                    thought.id[:8],
                    candidate,
                )
                return True
        return False

    def _build_continuity_context(self) -> str:
        """Build context from recent thoughts with anti-repetition guidance.

        Shows the last 5 thoughts (truncated) so the model knows what themes
        to avoid.  The header explicitly instructs against repeating.
        """
        recent = self.get_recent_thoughts(limit=5)
        if not recent:
            return "This is your first thought since waking up. Begin fresh."

        lines = ["Your recent thoughts — do NOT repeat these themes. Find a new angle:"]
        # recent is newest-first, reverse for chronological order
        for thought in reversed(recent):
            snippet = thought.content[:100]
            if len(thought.content) > 100:
                snippet = snippet.rstrip() + "..."
            lines.append(f"- [{thought.seed_type}] {snippet}")
        return "\n".join(lines)

    # ── Agent and storage ──────────────────────────────────────────────────

    _thinking_agent = None  # cached agent — avoids per-call resource leaks (#525)

    async def _call_agent(self, prompt: str) -> str:
        """Call Timmy's agent to generate a thought.

        Reuses a cached agent with skip_mcp=True to avoid the cancel-scope
        errors that occur when MCP stdio transports are spawned inside asyncio
        background tasks (#72) and to prevent per-call resource leaks (httpx
        clients, SQLite connections, model warmups) that caused the thinking
        loop to die every ~10 min (#525).

        Individual calls are capped at 120 s so a hung Ollama never blocks
        the scheduler indefinitely.

        Strips ``<think>`` tags from reasoning models (qwen3, etc.) so that
        downstream parsers (fact distillation, issue filing) receive clean text.
        """
        import asyncio

        if self._thinking_agent is None:
            from timmy.agent import create_timmy

            self._thinking_agent = create_timmy(skip_mcp=True)

        try:
            async with asyncio.timeout(120):
                run = await self._thinking_agent.arun(prompt, stream=False)
        except TimeoutError:
            logger.warning("Thinking LLM call timed out after 120 s")
            return ""

        raw = run.content if hasattr(run, "content") else str(run)
        return _THINK_TAG_RE.sub("", raw) if raw else raw

    def _store_thought(
        self,
        content: str,
        seed_type: str,
        *,
        arrived_at: str | None = None,
    ) -> Thought:
        """Persist a thought to SQLite.

        Args:
            arrived_at: ISO-8601 timestamp captured when the thinking cycle
                started.  Falls back to now() for callers that don't supply it.
        """
        thought = Thought(
            id=str(uuid.uuid4()),
            content=content,
            seed_type=seed_type,
            parent_id=self._last_thought_id,
            created_at=arrived_at or datetime.now(UTC).isoformat(),
        )

        with _get_conn(self._db_path) as conn:
            conn.execute(
                """
                INSERT INTO thoughts (id, content, seed_type, parent_id, created_at)
                VALUES (?, ?, ?, ?, ?)
                """,
                (
                    thought.id,
                    thought.content,
                    thought.seed_type,
                    thought.parent_id,
                    thought.created_at,
                ),
            )
            conn.commit()
        return thought

    def _log_event(self, thought: Thought) -> None:
        """Log the thought as a swarm event."""
        try:
            from swarm.event_log import EventType, log_event

            log_event(
                EventType.TIMMY_THOUGHT,
                source="thinking-engine",
                agent_id="default",
                data={
                    "thought_id": thought.id,
                    "seed_type": thought.seed_type,
                    "content": thought.content[:200],
                },
            )
        except Exception as exc:
            logger.debug("Failed to log thought event: %s", exc)

    def _write_journal(self, thought: Thought) -> None:
        """Append the thought to a daily markdown journal file.

        Writes to data/journal/YYYY-MM-DD.md — one file per day, append-only.
        Timestamps are converted to local time with timezone indicator.
        """
        try:
            ts = datetime.fromisoformat(thought.created_at)
            # Convert UTC to local for a human-readable journal
            local_ts = ts.astimezone()
            tz_name = local_ts.strftime("%Z") or "UTC"

            journal_dir = self._db_path.parent / "journal"
            journal_dir.mkdir(parents=True, exist_ok=True)

            journal_file = journal_dir / f"{local_ts.strftime('%Y-%m-%d')}.md"
            time_str = f"{local_ts.strftime('%I:%M %p').lstrip('0')} {tz_name}"

            entry = f"## {time_str} — {thought.seed_type}\n\n{thought.content}\n\n---\n\n"

            with open(journal_file, "a", encoding="utf-8") as f:
                f.write(entry)
        except Exception as exc:
            logger.debug("Failed to write journal entry: %s", exc)

    async def _broadcast(self, thought: Thought) -> None:
        """Broadcast the thought to WebSocket clients."""
        try:
            from infrastructure.ws_manager.handler import ws_manager

            await ws_manager.broadcast(
                "timmy_thought",
                {
                    "thought_id": thought.id,
                    "content": thought.content,
                    "seed_type": thought.seed_type,
                    "created_at": thought.created_at,
                },
            )
        except Exception as exc:
            logger.debug("Failed to broadcast thought: %s", exc)
