"""Sister Session — proactive autonomous work triggered by insights and dreams.

Each user session can have a paired "sister session" that receives tasks
generated by the nervous system (intuitions) and insights (deadlines).
Tasks are queued with priorities and, in Phase 2, executed autonomously
by a restricted agent.  Results are delivered as briefings.
"""

from __future__ import annotations

import json
import uuid
from datetime import UTC, datetime, date, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any

import aiosqlite

from captain_claw.config import get_config
from captain_claw.logging import get_logger

if TYPE_CHECKING:
    from captain_claw.agent import Agent

log = get_logger(__name__)

# ── Constants ────────────────────────────────────────────────────────

VALID_SOURCE_TYPES = frozenset({
    "intuition", "insight", "deadline", "watch", "manual",
})

VALID_TASK_STATUSES = frozenset({
    "queued", "running", "done", "failed", "dismissed",
})

VALID_BRIEFING_STATUSES = frozenset({
    "unread", "read", "dismissed", "acted",
})

_JSON_DUMPS = lambda obj: json.dumps(obj, default=str)


# ── Manager ──────────────────────────────────────────────────────────


class SisterSessionManager:
    """SQLite-backed task queue and briefing store for sister sessions."""

    def __init__(self, db_path: Path | str | None = None):
        cfg = get_config()
        if db_path is None:
            db_path = Path(cfg.sister_session.db_path).expanduser()
        self._db_path = Path(db_path)
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db: aiosqlite.Connection | None = None

    async def _get_db(self) -> aiosqlite.Connection:
        if self._db is None:
            self._db = await aiosqlite.connect(str(self._db_path))
            self._db.row_factory = aiosqlite.Row
            await self._db.execute("PRAGMA journal_mode=WAL")
            await self._db.execute("PRAGMA busy_timeout=5000")
            await self._init_schema()
        return self._db

    async def _init_schema(self) -> None:
        db = self._db
        assert db is not None
        await db.executescript("""
            CREATE TABLE IF NOT EXISTS proactive_tasks (
                id              TEXT PRIMARY KEY,
                parent_session_id TEXT NOT NULL,
                sister_session_id TEXT NOT NULL DEFAULT '',
                source_type     TEXT NOT NULL,
                source_id       TEXT NOT NULL,
                trigger_reason  TEXT NOT NULL,
                prompt          TEXT NOT NULL DEFAULT '',
                status          TEXT NOT NULL DEFAULT 'queued',
                priority        INTEGER NOT NULL DEFAULT 5,
                token_budget    INTEGER NOT NULL DEFAULT 10000,
                tokens_used     INTEGER NOT NULL DEFAULT 0,
                result_summary  TEXT NOT NULL DEFAULT '',
                result_body     TEXT NOT NULL DEFAULT '',
                actionable      INTEGER NOT NULL DEFAULT 0,
                confidence      REAL NOT NULL DEFAULT 0.0,
                created_at      TEXT NOT NULL,
                started_at      TEXT,
                completed_at    TEXT,
                error           TEXT
            );

            CREATE INDEX IF NOT EXISTS idx_tasks_parent_status
                ON proactive_tasks(parent_session_id, status);
            CREATE INDEX IF NOT EXISTS idx_tasks_priority
                ON proactive_tasks(status, priority DESC, created_at ASC);
            CREATE INDEX IF NOT EXISTS idx_tasks_source
                ON proactive_tasks(source_type, source_id);

            CREATE TABLE IF NOT EXISTS briefings (
                id              TEXT PRIMARY KEY,
                parent_session_id TEXT NOT NULL,
                source_type     TEXT NOT NULL,
                trigger_reason  TEXT NOT NULL,
                summary         TEXT NOT NULL,
                body            TEXT NOT NULL,
                actionable      INTEGER NOT NULL DEFAULT 0,
                confidence      REAL NOT NULL DEFAULT 0.0,
                status          TEXT NOT NULL DEFAULT 'unread',
                created_at      TEXT NOT NULL,
                read_at         TEXT,
                dismissed_at    TEXT
            );

            CREATE INDEX IF NOT EXISTS idx_briefings_parent_status
                ON briefings(parent_session_id, status);

            CREATE TABLE IF NOT EXISTS watches (
                id              TEXT PRIMARY KEY,
                parent_session_id TEXT NOT NULL,
                query           TEXT NOT NULL,
                check_type      TEXT NOT NULL DEFAULT 'search',
                check_target    TEXT NOT NULL DEFAULT '',
                interval_seconds INTEGER NOT NULL DEFAULT 3600,
                last_checked_at TEXT,
                next_check_at   TEXT NOT NULL,
                enabled         INTEGER NOT NULL DEFAULT 1,
                created_at      TEXT NOT NULL,
                metadata        TEXT NOT NULL DEFAULT '{}'
            );

            CREATE INDEX IF NOT EXISTS idx_watches_next
                ON watches(enabled, next_check_at);

            CREATE TABLE IF NOT EXISTS daily_budget (
                date            TEXT NOT NULL,
                session_id      TEXT NOT NULL,
                tokens_used     INTEGER NOT NULL DEFAULT 0,
                tasks_run       INTEGER NOT NULL DEFAULT 0,
                PRIMARY KEY (date, session_id)
            );
        """)
        await db.commit()

    async def clear_all(self) -> int:
        """Delete ALL proactive tasks, briefings, watches, and budget.  Returns total rows."""
        db = await self._get_db()
        total = 0
        for table in ("proactive_tasks", "briefings", "watches", "daily_budget"):
            cursor = await db.execute(f"DELETE FROM {table}")
            total += cursor.rowcount or 0
        await db.commit()
        return total

    # ── Task CRUD ────────────────────────────────────────────────────

    async def create_task(
        self,
        *,
        parent_session_id: str,
        source_type: str,
        source_id: str,
        trigger_reason: str,
        prompt: str = "",
        priority: int = 5,
        token_budget: int = 10000,
        sister_session_id: str = "",
    ) -> str:
        """Create a proactive task. Returns the task ID."""
        db = await self._get_db()
        task_id = uuid.uuid4().hex[:16]
        now = datetime.now(UTC).isoformat()
        await db.execute(
            """INSERT INTO proactive_tasks
               (id, parent_session_id, sister_session_id, source_type, source_id,
                trigger_reason, prompt, status, priority, token_budget, created_at)
               VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)""",
            (task_id, parent_session_id, sister_session_id, source_type,
             source_id, trigger_reason, prompt, priority, token_budget, now),
        )
        await db.commit()
        log.info("Proactive task created", task_id=task_id, source_type=source_type,
                 priority=priority, parent_session=parent_session_id)
        return task_id

    async def get_task(self, task_id: str) -> dict[str, Any] | None:
        """Get a single task by ID."""
        db = await self._get_db()
        async with db.execute(
            "SELECT * FROM proactive_tasks WHERE id = ?", (task_id,)
        ) as cur:
            row = await cur.fetchone()
            return dict(row) if row else None

    async def list_tasks(
        self,
        parent_session_id: str | None = None,
        *,
        status: str | None = None,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """List tasks, optionally filtered by parent session and status."""
        db = await self._get_db()
        clauses: list[str] = []
        params: list[Any] = []
        if parent_session_id:
            clauses.append("parent_session_id = ?")
            params.append(parent_session_id)
        if status:
            clauses.append("status = ?")
            params.append(status)
        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
        query = f"SELECT * FROM proactive_tasks {where} ORDER BY priority DESC, created_at ASC LIMIT ?"
        params.append(limit)
        async with db.execute(query, params) as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def update_task(self, task_id: str, **fields: Any) -> bool:
        """Update task fields."""
        if not fields:
            return False
        db = await self._get_db()
        sets = ", ".join(f"{k} = ?" for k in fields)
        vals = list(fields.values()) + [task_id]
        result = await db.execute(
            f"UPDATE proactive_tasks SET {sets} WHERE id = ?", vals,
        )
        await db.commit()
        return (result.rowcount or 0) > 0

    async def delete_task(self, task_id: str) -> bool:
        """Delete a task."""
        db = await self._get_db()
        result = await db.execute("DELETE FROM proactive_tasks WHERE id = ?", (task_id,))
        await db.commit()
        return (result.rowcount or 0) > 0

    async def get_next_task(self, parent_session_id: str) -> dict[str, Any] | None:
        """Get the highest-priority queued task for a parent session."""
        db = await self._get_db()
        async with db.execute(
            """SELECT * FROM proactive_tasks
               WHERE parent_session_id = ? AND status = 'queued'
               ORDER BY priority DESC, created_at ASC LIMIT 1""",
            (parent_session_id,),
        ) as cur:
            row = await cur.fetchone()
            return dict(row) if row else None

    async def count_tasks(
        self,
        parent_session_id: str | None = None,
        *,
        status: str | None = None,
    ) -> int:
        """Count tasks with optional filters."""
        db = await self._get_db()
        clauses: list[str] = []
        params: list[Any] = []
        if parent_session_id:
            clauses.append("parent_session_id = ?")
            params.append(parent_session_id)
        if status:
            clauses.append("status = ?")
            params.append(status)
        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
        async with db.execute(f"SELECT COUNT(*) FROM proactive_tasks {where}", params) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def count_recent_tasks(
        self,
        parent_session_id: str,
        since_seconds: int = 3600,
    ) -> int:
        """Count tasks created in the last N seconds."""
        db = await self._get_db()
        cutoff = (datetime.now(UTC) - timedelta(seconds=since_seconds)).isoformat()
        async with db.execute(
            """SELECT COUNT(*) FROM proactive_tasks
               WHERE parent_session_id = ? AND created_at >= ?""",
            (parent_session_id, cutoff),
        ) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def find_task_by_source(
        self,
        source_type: str,
        source_id: str,
    ) -> dict[str, Any] | None:
        """Find an existing task for the same source."""
        db = await self._get_db()
        async with db.execute(
            """SELECT * FROM proactive_tasks
               WHERE source_type = ? AND source_id = ?
               ORDER BY created_at DESC LIMIT 1""",
            (source_type, source_id),
        ) as cur:
            row = await cur.fetchone()
            return dict(row) if row else None

    # ── Briefing CRUD ────────────────────────────────────────────────

    async def create_briefing(
        self,
        *,
        id: str,
        parent_session_id: str,
        source_type: str,
        trigger_reason: str,
        summary: str,
        body: str,
        actionable: bool = False,
        confidence: float = 0.5,
    ) -> str:
        """Create a briefing linked to a completed task."""
        db = await self._get_db()
        now = datetime.now(UTC).isoformat()
        await db.execute(
            """INSERT OR REPLACE INTO briefings
               (id, parent_session_id, source_type, trigger_reason,
                summary, body, actionable, confidence, status, created_at)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?)""",
            (id, parent_session_id, source_type, trigger_reason,
             summary, body, int(actionable), confidence, now),
        )
        await db.commit()
        return id

    async def get_briefing(self, briefing_id: str) -> dict[str, Any] | None:
        """Get a single briefing."""
        db = await self._get_db()
        async with db.execute(
            "SELECT * FROM briefings WHERE id = ?", (briefing_id,)
        ) as cur:
            row = await cur.fetchone()
            return dict(row) if row else None

    async def list_briefings(
        self,
        parent_session_id: str | None = None,
        *,
        status: str | None = None,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """List briefings, optionally filtered."""
        db = await self._get_db()
        clauses: list[str] = []
        params: list[Any] = []
        if parent_session_id:
            clauses.append("parent_session_id = ?")
            params.append(parent_session_id)
        if status:
            clauses.append("status = ?")
            params.append(status)
        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
        query = f"SELECT * FROM briefings {where} ORDER BY created_at DESC LIMIT ?"
        params.append(limit)
        async with db.execute(query, params) as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def count_briefings(
        self,
        parent_session_id: str | None = None,
        *,
        status: str | None = None,
    ) -> int:
        """Count briefings with optional filters."""
        db = await self._get_db()
        clauses: list[str] = []
        params: list[Any] = []
        if parent_session_id:
            clauses.append("parent_session_id = ?")
            params.append(parent_session_id)
        if status:
            clauses.append("status = ?")
            params.append(status)
        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
        async with db.execute(f"SELECT COUNT(*) FROM briefings {where}", params) as cur:
            row = await cur.fetchone()
            return row[0] if row else 0

    async def update_briefing(self, briefing_id: str, **fields: Any) -> bool:
        """Update briefing fields (e.g. status, read_at)."""
        if not fields:
            return False
        db = await self._get_db()
        sets = ", ".join(f"{k} = ?" for k in fields)
        vals = list(fields.values()) + [briefing_id]
        result = await db.execute(
            f"UPDATE briefings SET {sets} WHERE id = ?", vals,
        )
        await db.commit()
        return (result.rowcount or 0) > 0

    async def delete_briefing(self, briefing_id: str) -> bool:
        """Delete a briefing."""
        db = await self._get_db()
        result = await db.execute("DELETE FROM briefings WHERE id = ?", (briefing_id,))
        await db.commit()
        return (result.rowcount or 0) > 0

    async def mark_read(self, briefing_id: str) -> bool:
        """Mark a briefing as read."""
        now = datetime.now(UTC).isoformat()
        return await self.update_briefing(
            briefing_id, status="read", read_at=now,
        )

    async def dismiss(self, briefing_id: str) -> bool:
        """Dismiss a briefing."""
        now = datetime.now(UTC).isoformat()
        return await self.update_briefing(
            briefing_id, status="dismissed", dismissed_at=now,
        )

    # ── Daily Budget ─────────────────────────────────────────────────

    async def get_daily_budget(
        self,
        date_str: str,
        session_id: str,
    ) -> dict[str, int]:
        """Get daily budget usage."""
        db = await self._get_db()
        async with db.execute(
            "SELECT tokens_used, tasks_run FROM daily_budget WHERE date = ? AND session_id = ?",
            (date_str, session_id),
        ) as cur:
            row = await cur.fetchone()
            if row:
                return {"tokens_used": row[0], "tasks_run": row[1]}
            return {"tokens_used": 0, "tasks_run": 0}

    async def increment_daily_budget(
        self,
        date_str: str,
        session_id: str,
        tokens: int,
    ) -> None:
        """Increment daily budget counters."""
        db = await self._get_db()
        await db.execute(
            """INSERT INTO daily_budget (date, session_id, tokens_used, tasks_run)
               VALUES (?, ?, ?, 1)
               ON CONFLICT(date, session_id) DO UPDATE SET
                   tokens_used = tokens_used + excluded.tokens_used,
                   tasks_run = tasks_run + 1""",
            (date_str, session_id, tokens),
        )
        await db.commit()

    # ── Watch CRUD ───────────────────────────────────────────────────

    async def create_watch(
        self,
        *,
        parent_session_id: str,
        query: str,
        check_type: str = "search",
        check_target: str = "",
        interval_seconds: int = 3600,
        metadata: dict[str, Any] | None = None,
    ) -> str:
        """Create a watch. Returns watch ID."""
        db = await self._get_db()
        watch_id = uuid.uuid4().hex[:16]
        now = datetime.now(UTC)
        next_check = (now + timedelta(seconds=interval_seconds)).isoformat()
        await db.execute(
            """INSERT INTO watches
               (id, parent_session_id, query, check_type, check_target,
                interval_seconds, next_check_at, enabled, created_at, metadata)
               VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)""",
            (watch_id, parent_session_id, query, check_type, check_target,
             interval_seconds, next_check, now.isoformat(),
             json.dumps(metadata or {})),
        )
        await db.commit()
        log.info("Watch created", watch_id=watch_id, query=query[:60],
                 interval=interval_seconds)
        return watch_id

    async def list_watches(
        self,
        parent_session_id: str | None = None,
        *,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """List watches."""
        db = await self._get_db()
        if parent_session_id:
            async with db.execute(
                "SELECT * FROM watches WHERE parent_session_id = ? ORDER BY created_at DESC LIMIT ?",
                (parent_session_id, limit),
            ) as cur:
                return [dict(r) for r in await cur.fetchall()]
        async with db.execute(
            "SELECT * FROM watches ORDER BY created_at DESC LIMIT ?", (limit,),
        ) as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def get_due_watches(self) -> list[dict[str, Any]]:
        """Get all enabled watches whose next_check_at has passed."""
        db = await self._get_db()
        now = datetime.now(UTC).isoformat()
        async with db.execute(
            "SELECT * FROM watches WHERE enabled = 1 AND next_check_at <= ?",
            (now,),
        ) as cur:
            return [dict(r) for r in await cur.fetchall()]

    async def update_watch_schedule(self, watch_id: str) -> None:
        """Advance a watch's next_check_at after it has been polled."""
        db = await self._get_db()
        now = datetime.now(UTC)
        # Read interval to compute next check
        async with db.execute(
            "SELECT interval_seconds FROM watches WHERE id = ?", (watch_id,),
        ) as cur:
            row = await cur.fetchone()
            if not row:
                return
            interval = row[0]
        next_check = (now + timedelta(seconds=interval)).isoformat()
        await db.execute(
            "UPDATE watches SET last_checked_at = ?, next_check_at = ? WHERE id = ?",
            (now.isoformat(), next_check, watch_id),
        )
        await db.commit()

    async def delete_watch(self, watch_id: str) -> bool:
        """Delete a watch."""
        db = await self._get_db()
        result = await db.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
        await db.commit()
        return (result.rowcount or 0) > 0

    # ── Stats ────────────────────────────────────────────────────────

    async def stats(self, parent_session_id: str | None = None) -> dict[str, Any]:
        """Aggregate statistics."""
        db = await self._get_db()

        def _where(extra: str = "") -> tuple[str, list[Any]]:
            clauses: list[str] = []
            params: list[Any] = []
            if parent_session_id:
                clauses.append("parent_session_id = ?")
                params.append(parent_session_id)
            if extra:
                clauses.append(extra)
            w = ("WHERE " + " AND ".join(clauses)) if clauses else ""
            return w, params

        # Tasks
        w, p = _where()
        async with db.execute(f"SELECT COUNT(*) FROM proactive_tasks {w}", p) as cur:
            total_tasks = (await cur.fetchone())[0]

        w, p = _where("status = 'queued'")
        async with db.execute(f"SELECT COUNT(*) FROM proactive_tasks {w}", p) as cur:
            queued_tasks = (await cur.fetchone())[0]

        w, p = _where("status = 'done'")
        async with db.execute(f"SELECT COUNT(*) FROM proactive_tasks {w}", p) as cur:
            done_tasks = (await cur.fetchone())[0]

        # Briefings
        w, p = _where()
        async with db.execute(f"SELECT COUNT(*) FROM briefings {w}", p) as cur:
            total_briefings = (await cur.fetchone())[0]

        w, p = _where("status = 'unread'")
        async with db.execute(f"SELECT COUNT(*) FROM briefings {w}", p) as cur:
            unread_briefings = (await cur.fetchone())[0]

        # Today's budget
        today = date.today().isoformat()
        if parent_session_id:
            budget = await self.get_daily_budget(today, parent_session_id)
        else:
            async with db.execute(
                "SELECT COALESCE(SUM(tokens_used), 0), COALESCE(SUM(tasks_run), 0) FROM daily_budget WHERE date = ?",
                (today,),
            ) as cur:
                row = await cur.fetchone()
                budget = {"tokens_used": row[0], "tasks_run": row[1]}

        return {
            "total_tasks": total_tasks,
            "queued_tasks": queued_tasks,
            "done_tasks": done_tasks,
            "total_briefings": total_briefings,
            "unread_briefings": unread_briefings,
            "tokens_used_today": budget["tokens_used"],
            "tasks_run_today": budget["tasks_run"],
        }


# ── Singletons ───────────────────────────────────────────────────────

_manager: SisterSessionManager | None = None
_session_managers: dict[str, SisterSessionManager] = {}


def get_sister_session_manager() -> SisterSessionManager:
    """Return the global sister session manager."""
    global _manager
    if _manager is None:
        _manager = SisterSessionManager()
    return _manager


def get_session_sister_manager(session_id: str) -> SisterSessionManager:
    """Return a per-session manager (for public mode)."""
    if session_id not in _session_managers:
        cfg = get_config()
        base = Path(cfg.sister_session.db_path).expanduser().parent
        session_db = base / "sister_sessions" / f"sister_{session_id}.db"
        _session_managers[session_id] = SisterSessionManager(db_path=session_db)
    return _session_managers[session_id]


# ── Task Sourcing ────────────────────────────────────────────────────


def _score_intuition_priority(intuition: dict[str, Any]) -> int:
    """Calculate task priority from an intuition."""
    conf = float(intuition.get("confidence", 0.5))
    imp = int(intuition.get("importance", 5))
    tt = intuition.get("thread_type", "association")
    validated = bool(intuition.get("validated"))

    base = 4
    if tt == "hypothesis":
        base = 6 if conf >= 0.7 else 5
        if conf >= 0.9:
            base = 7
    elif tt == "pattern" and imp >= 8:
        base = 5
    elif tt == "connection" and conf >= 0.8:
        base = 4

    if validated:
        base += 1

    return min(base, 10)


def _should_trigger_task(intuition: dict[str, Any], cfg: Any = None) -> bool:
    """Decide whether an intuition should trigger a proactive task."""
    if cfg is None:
        cfg = get_config().sister_session

    tt = intuition.get("thread_type", "association")
    conf = float(intuition.get("confidence", 0.5))
    imp = int(intuition.get("importance", 5))

    # Hypotheses are investigatable — the primary trigger
    if tt == "hypothesis" and conf >= cfg.min_intuition_confidence:
        return True

    # High-importance patterns worth validating
    if tt == "pattern" and imp >= cfg.min_intuition_importance and conf >= cfg.min_intuition_confidence:
        return True

    # Strong connections tagged actionable
    if tt == "connection" and conf >= 0.8:
        tags = intuition.get("tags", "") or ""
        if "actionable" in tags.lower():
            return True

    # Associations — never (they're contextual, not investigatable)
    return False


async def maybe_create_proactive_task(
    parent_session_id: str,
    source_type: str,
    source_id: str,
    trigger_reason: str,
    priority: int = 5,
) -> str | None:
    """Create a proactive task if budget/dedup/limits allow. Returns task ID or None."""
    cfg = get_config()
    ss_cfg = cfg.sister_session

    if not ss_cfg.enabled:
        return None

    # Public mode guard
    if cfg.web.public_run and not ss_cfg.allow_public:
        return None

    # Resolve manager
    if cfg.web.public_run:
        mgr = get_session_sister_manager(parent_session_id)
    else:
        mgr = get_sister_session_manager()

    # 1. Dedup — don't create duplicate tasks for the same source
    existing = await mgr.find_task_by_source(source_type, source_id)
    if existing and existing["status"] in ("queued", "running", "done"):
        return None

    # 2. Rate limit — max tasks per hour per session
    recent_count = await mgr.count_recent_tasks(parent_session_id, since_seconds=3600)
    if recent_count >= ss_cfg.max_tasks_per_hour:
        log.info("Sister session rate limit hit", session=parent_session_id)
        return None

    # 3. Daily budget — check token budget
    today = date.today().isoformat()
    budget = await mgr.get_daily_budget(today, parent_session_id)
    if budget["tokens_used"] >= ss_cfg.max_tokens_per_day:
        log.info("Sister session daily budget exhausted", session=parent_session_id)
        return None

    # 4. Queue depth — don't pile up too many tasks
    queued_count = await mgr.count_tasks(parent_session_id, status="queued")
    if queued_count >= ss_cfg.max_queued_tasks:
        log.info("Sister session queue full", session=parent_session_id)
        return None

    # 5. Create the task
    task_id = await mgr.create_task(
        parent_session_id=parent_session_id,
        source_type=source_type,
        source_id=source_id,
        trigger_reason=trigger_reason,
        priority=priority,
        token_budget=ss_cfg.default_token_budget,
    )

    # Fire-and-forget: trigger execution of queued tasks
    if task_id:
        import asyncio
        asyncio.create_task(maybe_execute_tasks(parent_session_id))

    return task_id


# ── Hooks for nervous system and insights ────────────────────────────


async def on_intuitions_stored(
    agent: "Agent",
    stored: list[dict[str, Any]],
) -> None:
    """Called after nervous system dream — check if any intuitions should trigger tasks."""
    cfg = get_config()
    if not cfg.sister_session.enabled or not cfg.sister_session.auto_trigger_intuitions:
        return
    if not agent.session:
        return

    session_id = str(agent.session.id)
    for intuition in stored:
        if _should_trigger_task(intuition, cfg.sister_session):
            await maybe_create_proactive_task(
                parent_session_id=session_id,
                source_type="intuition",
                source_id=str(intuition.get("id", "")),
                trigger_reason=f"Investigate {intuition.get('thread_type', 'hypothesis')}: {str(intuition.get('content', ''))[:100]}",
                priority=_score_intuition_priority(intuition),
            )


async def on_insights_stored(
    agent: "Agent",
    stored: list[dict[str, Any]],
) -> None:
    """Called after insight extraction — check for approaching deadlines."""
    cfg = get_config()
    if not cfg.sister_session.enabled or not cfg.sister_session.auto_trigger_deadlines:
        return
    if not agent.session:
        return

    session_id = str(agent.session.id)
    for insight in stored:
        cat = str(insight.get("category", "")).lower()
        content = str(insight.get("content", "")).lower()
        # Heuristic: look for deadline-related insights
        if cat in ("deadline", "date", "schedule", "reminder") or any(
            kw in content for kw in ("due", "deadline", "by friday", "by monday",
                                      "by tomorrow", "expires", "expiring",
                                      "due date", "overdue")
        ):
            await maybe_create_proactive_task(
                parent_session_id=session_id,
                source_type="deadline",
                source_id=str(insight.get("id", "")),
                trigger_reason=f"Deadline-related: {str(insight.get('content', ''))[:100]}",
                priority=9,
            )


# ── Execution Engine (Phase 2) ──────────────────────────────────────

# Track running executions to prevent concurrent runs per parent session.
_execution_locks: dict[str, bool] = {}

# Global broadcast callback set by the web server at startup.
_broadcast_callback: Any = None


def set_broadcast_callback(cb: Any) -> None:
    """Set the global broadcast callback (called by web_server on init)."""
    global _broadcast_callback
    _broadcast_callback = cb


def _notify_briefing_ready(parent_session_id: str, summary: str, unread: int) -> None:
    """Send WebSocket notification that a briefing is ready."""
    if _broadcast_callback is None:
        return
    try:
        _broadcast_callback({
            "type": "briefing_ready",
            "session_id": parent_session_id,
            "summary": summary,
            "unread_count": unread,
        })
    except Exception:
        pass


def _register_sister_tools(agent: "Agent") -> None:
    """Register only safe, read-only tools for the sister agent."""
    from captain_claw.tools import (
        GlobTool,
        ReadTool,
        WebFetchTool,
        WebGetTool,
        WebSearchTool,
    )

    agent.tools.register(WebSearchTool())
    agent.tools.register(WebFetchTool())
    agent.tools.register(WebGetTool())
    agent.tools.register(ReadTool())
    agent.tools.register(GlobTool())

    # Memory search tools (read-only)
    try:
        from captain_claw.tools import InsightsTool
        agent.tools.register(InsightsTool())
    except Exception:
        pass

    try:
        dm = getattr(agent, "_deep_memory", None)
        if dm is not None:
            from captain_claw.tools import TypesenseTool
            agent.tools.register(TypesenseTool(deep_memory=dm))
    except Exception:
        pass


async def _create_sister_agent(
    parent_session_id: str,
    thinking_callback: Any = None,
) -> "Agent":
    """Create a restricted agent for sister session execution."""
    from captain_claw.agent import Agent
    from captain_claw.instructions import InstructionLoader
    from captain_claw.llm import get_provider
    from captain_claw.session import get_session_manager

    cfg = get_config()

    agent = Agent(
        provider=get_provider(),
        approval_callback=lambda msg: True,
        thinking_callback=thinking_callback,
    )

    # Create or load sister session
    sm = get_session_manager()
    parent = await sm.load_session(parent_session_id)
    parent_name = parent.name if parent else "unnamed"

    # Check if parent already has a linked sister session
    sister_session_id = (parent.metadata or {}).get("sister_session_id") if parent else None
    sister_session = None

    if sister_session_id:
        sister_session = await sm.load_session(sister_session_id)

    if sister_session is None:
        sister_session = await sm.create_session(
            name=f"sister::{parent_name}",
            metadata={
                "is_sister": True,
                "parent_session_id": parent_session_id,
            },
        )
        # Link parent → sister
        if parent:
            if parent.metadata is None:
                parent.metadata = {}
            parent.metadata["sister_session_id"] = sister_session.id
            await sm.save_session(parent)

    agent.session = sister_session
    agent.session_manager = sm

    # Restrict capabilities
    agent._is_worker = True
    agent.max_iterations = cfg.sister_session.max_iterations
    agent.planning_enabled = False

    # Register ONLY read-only tools
    _register_sister_tools(agent)

    # Load instructions
    agent.instructions = InstructionLoader()
    agent._initialized = True

    return agent


async def _build_investigation_prompt(
    task: dict[str, Any],
    parent_session_id: str,
) -> str:
    """Build the prompt that the sister agent will execute."""
    from captain_claw.instructions import InstructionLoader
    from captain_claw.session import get_session_manager

    cfg = get_config()
    loader = InstructionLoader()

    # Recent context from parent session
    recent_context = "(No recent context available)"
    try:
        sm = get_session_manager()
        parent = await sm.load_session(parent_session_id)
        if parent and parent.messages:
            msgs = parent.messages[-8:]
            lines: list[str] = []
            for m in msgs:
                role = m.get("role", "unknown")
                content = str(m.get("content", ""))[:400]
                if role in ("user", "assistant"):
                    lines.append(f"[{role}] {content}")
            if lines:
                recent_context = "\n".join(lines)
    except Exception:
        pass

    # Related insights
    insights_text = "(No insights available)"
    try:
        from captain_claw.insights import get_insights_manager
        mgr = get_insights_manager()
        items = await mgr.search(task.get("trigger_reason", "")[:60], limit=5)
        if items:
            ins_lines = [f"- [{i.get('category', 'fact')}] {i['content']}" for i in items]
            insights_text = "\n".join(ins_lines)
    except Exception:
        pass

    # Related intuitions
    intuitions_text = "(No intuitions available)"
    try:
        from captain_claw.nervous_system import get_nervous_system_manager
        mgr = get_nervous_system_manager()
        items = await mgr.search(task.get("trigger_reason", "")[:60], limit=3)
        if items:
            int_lines = [f"- [{i.get('thread_type', 'association')}] {i['content']}" for i in items]
            intuitions_text = "\n".join(int_lines)
    except Exception:
        pass

    # Render the prompt
    try:
        rendered = loader.render(
            "sister_investigation_user_prompt.md",
            source_type=task.get("source_type", "unknown"),
            trigger_reason=task.get("trigger_reason", ""),
            recent_context=recent_context,
            insights_text=insights_text,
            intuitions_text=intuitions_text,
        )
    except Exception:
        rendered = (
            f"Investigate: {task.get('trigger_reason', '')}\n\n"
            f"Context:\n{recent_context}\n\n"
            f"Insights:\n{insights_text}"
        )

    return rendered


def _parse_investigation_result(
    text: str,
) -> tuple[str, bool, float, list[str]]:
    """Parse the structured JSON block from the agent's response.

    Returns (summary, actionable, confidence, tags).
    """
    import re

    # Try to find JSON block
    match = re.search(r'```json\s*\n(\{.*?\})\s*\n```', text, re.DOTALL)
    if match:
        try:
            data = json.loads(match.group(1))
            summary = str(data.get("summary", ""))[:200]
            actionable = bool(data.get("actionable", False))
            confidence = float(data.get("confidence", 0.5))
            tags = data.get("tags", [])
            if isinstance(tags, list):
                tags = [str(t) for t in tags[:5]]
            else:
                tags = []
            return summary, actionable, confidence, tags
        except (json.JSONDecodeError, ValueError):
            pass

    # Fallback: first sentence as summary
    first_line = text.strip().split("\n")[0][:200]
    return first_line, False, 0.5, []


async def execute_task(task: dict[str, Any]) -> dict[str, Any] | None:
    """Execute a single proactive task autonomously. Returns the briefing dict or None."""
    import asyncio

    cfg = get_config()
    ss_cfg = cfg.sister_session
    parent_session_id = task["parent_session_id"]

    # Resolve manager
    if cfg.web.public_run:
        mgr = get_session_sister_manager(parent_session_id)
    else:
        mgr = get_sister_session_manager()

    now_iso = datetime.now(UTC).isoformat()
    await mgr.update_task(task["id"], status="running", started_at=now_iso)

    # Build thinking callback that tags output with [sister]
    def _sister_thinking(text: str, tool: str = "", phase: str = "tool") -> None:
        if _broadcast_callback:
            try:
                _broadcast_callback({
                    "type": "thinking",
                    "text": f"[sister] {text}",
                    "tool": tool,
                    "phase": phase,
                })
            except Exception:
                pass

    try:
        # 1. Build the investigation prompt
        prompt = await _build_investigation_prompt(task, parent_session_id)

        # 2. Create the sister agent
        agent = await _create_sister_agent(
            parent_session_id,
            thinking_callback=_sister_thinking,
        )

        # 3. Load system prompt for investigations
        try:
            from captain_claw.instructions import InstructionLoader
            sys_prompt = InstructionLoader().load("sister_investigation_system_prompt.md")
            # Prepend system instructions to the prompt
            full_prompt = f"{sys_prompt}\n\n---\n\n{prompt}"
        except Exception:
            full_prompt = prompt

        _sister_thinking(
            f"Investigating: {task.get('trigger_reason', '')[:80]}",
            tool="sister_session",
            phase="tool",
        )

        # 4. Execute with timeout
        response = await asyncio.wait_for(
            agent.complete(full_prompt),
            timeout=180.0,  # 3-minute hard timeout
        )

        result_text = str(response or "")

        # 5. Parse result
        summary, actionable, confidence, tags = _parse_investigation_result(result_text)
        if not summary:
            summary = f"Investigation completed: {task.get('trigger_reason', '')[:150]}"

        # 6. Estimate tokens used
        tokens_used = len(result_text) // 4 + len(full_prompt) // 4

        # 7. Create briefing
        await mgr.create_briefing(
            id=task["id"],
            parent_session_id=parent_session_id,
            source_type=task["source_type"],
            trigger_reason=task["trigger_reason"],
            summary=summary,
            body=result_text,
            actionable=actionable,
            confidence=confidence,
        )

        # 8. Update task
        done_iso = datetime.now(UTC).isoformat()
        await mgr.update_task(
            task["id"],
            status="done",
            completed_at=done_iso,
            result_summary=summary,
            result_body=result_text,
            actionable=int(actionable),
            confidence=confidence,
            tokens_used=tokens_used,
        )

        # 9. Update daily budget
        today = date.today().isoformat()
        await mgr.increment_daily_budget(today, parent_session_id, tokens_used)

        # 10. Save sister session
        try:
            agent.session.add_message(
                role="tool",
                content=f"[SISTER] Task completed: {summary}",
                tool_name="sister_session",
            )
            await agent.session_manager.save_session(agent.session)
        except Exception:
            pass

        # 11. Notify via WebSocket
        unread = await mgr.count_briefings(parent_session_id, status="unread")
        _notify_briefing_ready(parent_session_id, summary, unread)

        _sister_thinking(
            f"Briefing ready: {summary}",
            tool="sister_session",
            phase="tool",
        )

        log.info(
            "Sister session task completed",
            task_id=task["id"],
            tokens_used=tokens_used,
            actionable=actionable,
            confidence=confidence,
        )

        return {
            "id": task["id"],
            "summary": summary,
            "actionable": actionable,
            "confidence": confidence,
        }

    except asyncio.TimeoutError:
        await mgr.update_task(task["id"], status="failed", error="timeout (3 min)")
        log.warning("Sister session task timed out", task_id=task["id"])
        return None
    except Exception as exc:
        await mgr.update_task(task["id"], status="failed", error=str(exc)[:500])
        log.warning("Sister session task failed", task_id=task["id"], error=str(exc))
        return None


async def execute_next_task(parent_session_id: str) -> None:
    """Execute the next queued task for a parent session."""
    import asyncio

    cfg = get_config()
    ss_cfg = cfg.sister_session

    if not ss_cfg.enabled:
        return

    # Prevent concurrent executions for the same parent session
    if _execution_locks.get(parent_session_id, False):
        return
    _execution_locks[parent_session_id] = True

    try:
        # Rate limit check
        mgr = get_sister_session_manager()
        if cfg.web.public_run:
            mgr = get_session_sister_manager(parent_session_id)

        recent = await mgr.count_recent_tasks(parent_session_id, since_seconds=3600)
        if recent >= ss_cfg.max_tasks_per_hour:
            return

        today = date.today().isoformat()
        budget = await mgr.get_daily_budget(today, parent_session_id)
        if budget["tokens_used"] >= ss_cfg.max_tokens_per_day:
            return

        # Get next task
        task = await mgr.get_next_task(parent_session_id)
        if not task:
            return

        # Execute
        await execute_task(task)

        # Check for more queued tasks
        remaining = await mgr.count_tasks(parent_session_id, status="queued")
        if remaining > 0:
            await asyncio.sleep(5)
            asyncio.create_task(execute_next_task(parent_session_id))

    except Exception as exc:
        log.warning("Sister session execution loop failed", error=str(exc))
    finally:
        _execution_locks[parent_session_id] = False


async def maybe_execute_tasks(parent_session_id: str) -> None:
    """Fire-and-forget trigger: schedule task execution if there are queued tasks."""
    import asyncio

    cfg = get_config()
    if not cfg.sister_session.enabled:
        return

    try:
        mgr = get_sister_session_manager()
        if cfg.web.public_run:
            mgr = get_session_sister_manager(parent_session_id)

        queued = await mgr.count_tasks(parent_session_id, status="queued")
        if queued > 0 and not _execution_locks.get(parent_session_id, False):
            asyncio.create_task(execute_next_task(parent_session_id))
    except Exception:
        pass


# ── Watch Polling ────────────────────────────────────────────────────


async def poll_due_watches() -> None:
    """Check all due watches and create tasks for them."""
    cfg = get_config()
    if not cfg.sister_session.enabled:
        return

    try:
        mgr = get_sister_session_manager()
        watches = await mgr.get_due_watches()

        for watch in watches:
            parent_session_id = watch["parent_session_id"]
            watch_id = watch["id"]
            query = watch.get("query", "")

            # Create a task from the watch
            await maybe_create_proactive_task(
                parent_session_id=parent_session_id,
                source_type="watch",
                source_id=watch_id,
                trigger_reason=f"Watch check: {query[:120]}",
                priority=3,
            )

            # Advance the watch schedule
            await mgr.update_watch_schedule(watch_id)

            log.debug("Watch polled", watch_id=watch_id, query=query[:40])

        # After creating watch tasks, trigger execution for each session
        sessions_with_tasks: set[str] = set()
        for watch in watches:
            sessions_with_tasks.add(watch["parent_session_id"])
        for session_id in sessions_with_tasks:
            await maybe_execute_tasks(session_id)

    except Exception as exc:
        log.debug("Watch polling failed (non-fatal)", error=str(exc))
