"""Persistent async background task queue — hybrid FileStore + SQLite.

Persistent tables (git-tracked via state/ JSON files):
  - kanban_tasks
  - delegation_reports

Ephemeral tables (local SQLite, regenerated per machine):
  - background_tasks
  - notifications
"""

from __future__ import annotations

import contextlib
import json
import logging
import sqlite3
import uuid
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from pathlib import Path
from typing import Any

from vastian.storage.file_store import FileStore

logger = logging.getLogger(__name__)


class TaskStatus(StrEnum):
    QUEUED = "queued"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"


class BackgroundTask:
    """A single background task with persistence."""

    def __init__(
        self,
        task_id: str,
        task_type: str,
        agent_id: str,
        description: str,
        payload: dict[str, Any] | None = None,
        status: TaskStatus = TaskStatus.QUEUED,
        created_at: str | None = None,
        completed_at: str | None = None,
        result: str | None = None,
    ):
        self.task_id = task_id
        self.task_type = task_type
        self.agent_id = agent_id
        self.description = description
        self.payload = payload or {}
        self.status = status
        self.created_at = created_at or datetime.now(UTC).isoformat()
        self.completed_at = completed_at
        self.result = result

    def to_dict(self) -> dict[str, Any]:
        return {
            "task_id": self.task_id,
            "task_type": self.task_type,
            "agent_id": self.agent_id,
            "description": self.description,
            "payload": self.payload,
            "status": self.status.value,
            "created_at": self.created_at,
            "completed_at": self.completed_at,
            "result": self.result,
        }


class TaskQueue:
    """
    Hybrid task queue: FileStore for persistent Kanban/report state,
    SQLite for ephemeral background tasks and notifications.

    The FileStore-backed data (kanban_tasks, delegation_reports) is stored
    in state/*.json files that sync via git.
    """

    def __init__(self, db_path: Path, state_dir: Path | None = None) -> None:
        self.db_path = db_path
        self._conn: sqlite3.Connection | None = None

        # FileStore instances for persistent (git-tracked) data
        sd = state_dir or Path("./state")
        self._kanban = FileStore(sd / "kanban.json", id_field="kanban_id")
        self._reports = FileStore(sd / "delegation-reports.json", id_field="report_id")

    def initialize(self) -> None:
        """Create ephemeral SQLite tables (background_tasks, notifications)."""
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS background_tasks (
                task_id TEXT PRIMARY KEY,
                task_type TEXT NOT NULL,
                agent_id TEXT NOT NULL,
                description TEXT NOT NULL,
                payload TEXT DEFAULT '{}',
                status TEXT DEFAULT 'queued',
                created_at TEXT NOT NULL,
                started_at TEXT,
                completed_at TEXT,
                result TEXT
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS notifications (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                task_id TEXT,
                agent_id TEXT NOT NULL,
                message TEXT NOT NULL,
                notification_type TEXT DEFAULT 'info',
                created_at TEXT NOT NULL,
                read INTEGER DEFAULT 0
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS pending_approvals (
                approval_id TEXT PRIMARY KEY,
                agent_id TEXT NOT NULL,
                approval_type TEXT NOT NULL DEFAULT 'transaction',
                description TEXT NOT NULL,
                payload TEXT DEFAULT '{}',
                status TEXT DEFAULT 'pending',
                created_at TEXT NOT NULL,
                resolved_at TEXT
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS outreach_requests (
                outreach_id TEXT PRIMARY KEY,
                agent_id TEXT NOT NULL,
                subject TEXT NOT NULL,
                message TEXT NOT NULL,
                priority TEXT DEFAULT 'normal',
                status TEXT DEFAULT 'pending',
                created_at TEXT NOT NULL,
                delivered_at TEXT,
                response TEXT,
                scheduled_at TEXT
            )
        """)
        # Migration: add scheduled_at column if table already exists without it
        try:
            self._conn.execute("SELECT scheduled_at FROM outreach_requests LIMIT 1")
        except Exception:
            with contextlib.suppress(Exception):
                self._conn.execute("ALTER TABLE outreach_requests ADD COLUMN scheduled_at TEXT")

        # Key-value store for persistent timestamps (loop timers, etc.)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS kv_store (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL
            )
        """)

        # Suggestions table — agent-generated suggestions with approval flow
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS suggestions (
                id TEXT PRIMARY KEY,
                type TEXT NOT NULL,
                agent_id TEXT NOT NULL,
                title TEXT NOT NULL,
                description TEXT DEFAULT '',
                coach_type TEXT DEFAULT '',
                payload_json TEXT DEFAULT '{}',
                status TEXT DEFAULT 'pending',
                created_at TEXT NOT NULL,
                resolved_at TEXT,
                user_id TEXT DEFAULT 'owner'
            )
        """)

        # Token usage tracking table
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS token_usage (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                agent_id TEXT NOT NULL,
                user_id TEXT DEFAULT 'owner',
                provider TEXT NOT NULL,
                model TEXT NOT NULL,
                input_tokens INTEGER DEFAULT 0,
                output_tokens INTEGER DEFAULT 0,
                timestamp TEXT NOT NULL
            )
        """)

        self._conn.commit()

        # Clean up stale "running" tasks from previous sessions
        # These are tasks whose asyncio coroutine was killed when the CLI exited
        cleaned = self._cleanup_stale_running_tasks()
        if cleaned:
            logger.info("Cleaned up %d stale running task(s) from a prior session", cleaned)

        logger.info(
            "TaskQueue initialized (ephemeral: %s, persistent: %s)",
            self.db_path,
            self._kanban.path.parent,
        )

    def _cleanup_stale_running_tasks(self, max_age_minutes: int = 10) -> int:
        """Mark 'running' tasks as completed/failed if they've been running too long.

        When the CLI exits, any asyncio tasks that were mid-execution get killed
        but their SQLite status stays 'running' forever. This catches those orphans.

        Post-chat reviews are routine background work — if they get interrupted by
        shutdown, silently mark them as completed (not failed) to avoid noisy
        "failed task needs attention" alerts on the next login.
        """
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        # Find running tasks older than max_age_minutes
        cutoff = datetime.now(UTC)
        rows = self._conn.execute(
            "SELECT task_id, task_type, started_at, description, agent_id FROM background_tasks WHERE status='running'"
        ).fetchall()
        cleaned = 0
        for row in rows:
            task_id, task_type, started_at, description, agent_id = row
            if started_at:
                try:
                    started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
                    age = (cutoff - started).total_seconds() / 60
                    if age < max_age_minutes:
                        continue  # Still within time limit, leave it
                except (ValueError, TypeError):
                    pass  # Can't parse timestamp, clean it up

            # Post-chat reviews interrupted by shutdown are harmless — silently complete them.
            # Also catch previously-failed post_chat_review tasks that were marked as failed
            # before we added the CancelledError handler.
            if task_type == "post_chat_review":
                self._conn.execute(
                    "UPDATE background_tasks SET status='completed', completed_at=?, result=? WHERE task_id=?",
                    (now, "Session ended before completion (non-critical)", task_id),
                )
            elif task_type in ("delegation", "inbox_processing"):
                # Delegation / inbox tasks interrupted by shutdown should be retried
                # once — requeue them so the agent can finish the work.
                prev_result = self._conn.execute(
                    "SELECT result FROM background_tasks WHERE task_id=?", (task_id,)
                ).fetchone()
                already_retried = prev_result and prev_result[0] and "Requeued" in prev_result[0]
                if already_retried:
                    # Already retried once — give up to avoid infinite loops
                    self._conn.execute(
                        "UPDATE background_tasks SET status='failed', completed_at=?, result=? WHERE task_id=?",
                        (now, "Failed after retry: orphaned twice from prior sessions", task_id),
                    )
                    self._add_notification(
                        task_id,
                        agent_id,
                        f"Task failed after retry: {description[:80]}",
                        "failed",
                    )
                else:
                    # Requeue for automatic retry on next session
                    self._conn.execute(
                        "UPDATE background_tasks SET status='queued', started_at=NULL, completed_at=NULL, result=? WHERE task_id=?",
                        ("Requeued: orphaned from a prior session", task_id),
                    )
                    logger.info("Requeued orphaned %s task %s for retry", task_type, task_id)
            else:
                # Other task types are real failures — mark as failed and notify
                self._conn.execute(
                    "UPDATE background_tasks SET status='failed', completed_at=?, result=? WHERE task_id=?",
                    (
                        now,
                        "Cleaned up: orphaned from a prior session (process exited before completion)",
                        task_id,
                    ),
                )
                self._add_notification(
                    task_id,
                    agent_id,
                    f"Stale task cleaned up: {description[:80]} (prior session ended before completion)",
                    "info",
                )
            cleaned += 1

        # Also silently complete any post_chat_review tasks that were marked as
        # 'failed' in a prior session — these are not actionable and just add noise
        # to sync suggestions and /check-in.
        failed_pcr = self._conn.execute(
            "SELECT task_id FROM background_tasks WHERE status='failed' AND task_type='post_chat_review'"
        ).fetchall()
        for (pcr_id,) in failed_pcr:
            self._conn.execute(
                "UPDATE background_tasks SET status='completed', completed_at=?, result=? WHERE task_id=?",
                (now, "Reclassified: post-chat review failure is non-critical", pcr_id),
            )
            cleaned += 1

        if cleaned:
            self._conn.commit()
        return cleaned

    # ── Background Tasks (SQLite — ephemeral) ─────────

    def enqueue(
        self,
        task_type: str,
        agent_id: str,
        description: str,
        payload: dict[str, Any] | None = None,
    ) -> BackgroundTask:
        """Add a task to the queue. Returns the task object."""
        task = BackgroundTask(
            task_id=uuid.uuid4().hex[:12],
            task_type=task_type,
            agent_id=agent_id,
            description=description,
            payload=payload,
        )
        assert self._conn is not None
        self._conn.execute(
            """INSERT INTO background_tasks
               (task_id, task_type, agent_id, description, payload, status, created_at)
               VALUES (?, ?, ?, ?, ?, ?, ?)""",
            (
                task.task_id,
                task.task_type,
                task.agent_id,
                task.description,
                json.dumps(task.payload),
                task.status.value,
                task.created_at,
            ),
        )
        self._conn.commit()
        self._add_notification(task.task_id, agent_id, f"Task queued: {description}", "queued")
        logger.info("Task enqueued: %s — %s (%s)", task.task_id, description, agent_id)
        return task

    def mark_running(self, task_id: str) -> None:
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "UPDATE background_tasks SET status='running', started_at=? WHERE task_id=?",
            (now, task_id),
        )
        self._conn.commit()

    def mark_completed(self, task_id: str, result: str = "") -> None:
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "UPDATE background_tasks SET status='completed', completed_at=?, result=? WHERE task_id=?",
            (now, result[:2000], task_id),
        )
        self._conn.commit()
        row = self._conn.execute(
            "SELECT agent_id, description FROM background_tasks WHERE task_id=?",
            (task_id,),
        ).fetchone()
        if row:
            self._add_notification(task_id, row[0], f"Completed: {row[1]}", "completed")

    def mark_failed(self, task_id: str, error: str = "") -> None:
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "UPDATE background_tasks SET status='failed', completed_at=?, result=? WHERE task_id=?",
            (now, f"ERROR: {error[:2000]}", task_id),
        )
        self._conn.commit()
        row = self._conn.execute(
            "SELECT agent_id, description FROM background_tasks WHERE task_id=?",
            (task_id,),
        ).fetchone()
        if row:
            self._add_notification(task_id, row[0], f"Failed: {row[1]} — {error[:100]}", "failed")

    def get_pending(self) -> list[BackgroundTask]:
        """Get all queued tasks (not yet running)."""
        assert self._conn is not None
        rows = self._conn.execute(
            "SELECT * FROM background_tasks WHERE status='queued' ORDER BY created_at",
        ).fetchall()
        return [self._row_to_task(r) for r in rows]

    def get_running(self) -> list[BackgroundTask]:
        assert self._conn is not None
        rows = self._conn.execute(
            "SELECT * FROM background_tasks WHERE status='running' ORDER BY created_at",
        ).fetchall()
        return [self._row_to_task(r) for r in rows]

    def get_task(self, task_id: str) -> BackgroundTask | None:
        """Fetch a single task by ID. Returns ``None`` if not found."""
        assert self._conn is not None
        row = self._conn.execute(
            "SELECT * FROM background_tasks WHERE task_id=?",
            (task_id,),
        ).fetchone()
        return self._row_to_task(row) if row else None

    def get_recent(self, limit: int = 20) -> list[BackgroundTask]:
        """Get most recent tasks regardless of status."""
        assert self._conn is not None
        rows = self._conn.execute(
            "SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return [self._row_to_task(r) for r in rows]

    # ── Notifications (SQLite — ephemeral) ────────────

    def get_notifications(self, limit: int = 50, unread_only: bool = False) -> list[dict]:
        """Get notifications for the feed."""
        assert self._conn is not None
        if unread_only:
            rows = self._conn.execute(
                "SELECT * FROM notifications WHERE read=0 ORDER BY created_at DESC LIMIT ?",
                (limit,),
            ).fetchall()
        else:
            rows = self._conn.execute(
                "SELECT * FROM notifications ORDER BY created_at DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [
            {
                "id": r[0],
                "task_id": r[1],
                "agent_id": r[2],
                "message": r[3],
                "type": r[4],
                "created_at": r[5],
                "read": bool(r[6]),
            }
            for r in rows
        ]

    def _add_notification(
        self, task_id: str | None, agent_id: str, message: str, ntype: str
    ) -> None:
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "INSERT INTO notifications (task_id, agent_id, message, notification_type, created_at) VALUES (?,?,?,?,?)",
            (task_id, agent_id, message, ntype, now),
        )
        self._conn.commit()

    def add_notification(
        self,
        agent_id: str,
        message: str,
        ntype: str = "info",
        task_id: str | None = None,
    ) -> None:
        """Public notification — for custom messages (not tied to a task)."""
        self._add_notification(task_id, agent_id, message, ntype)

    def _row_to_task(self, row: tuple) -> BackgroundTask:
        return BackgroundTask(
            task_id=row[0],
            task_type=row[1],
            agent_id=row[2],
            description=row[3],
            payload=json.loads(row[4]) if row[4] else {},
            status=TaskStatus(row[5]),
            created_at=row[6],
            completed_at=row[8],
            result=row[9],
        )

    # ── Delegation Reports (FileStore — persistent) ───

    def save_delegation_report(
        self,
        parent_task_id: str,
        agent_id: str,
        description: str,
        full_result: str,
        tools_used: list[str] | None = None,
        documents_written: list[str] | None = None,
        status: str = "completed",
        user_id: str = "owner",
    ) -> str:
        """Save a detailed delegation report. Returns report_id."""
        report_id = uuid.uuid4().hex[:12]
        now = datetime.now(UTC).isoformat()
        record = {
            "report_id": report_id,
            "parent_task_id": parent_task_id,
            "agent_id": agent_id,
            "user_id": user_id,
            "description": description,
            "full_result": full_result,
            "tools_used": tools_used or [],
            "documents_written": documents_written or [],
            "status": status,
            "created_at": now,
        }
        self._reports.insert(record)
        return report_id

    def get_reports_for_task(self, task_id: str) -> list[dict]:
        """Get all delegation reports for a parent task."""
        results = self._reports.query(lambda r: r.get("parent_task_id") == task_id)
        results.sort(key=lambda r: r.get("created_at", ""))
        return results

    def get_reports_for_agent(self, agent_id: str, limit: int = 10) -> list[dict]:
        """Get recent delegation reports for a specific agent."""
        results = self._reports.query(lambda r: r.get("agent_id") == agent_id)
        results.sort(key=lambda r: r.get("created_at", ""), reverse=True)
        return results[:limit]

    def get_report(self, report_id: str) -> dict | None:
        """Get a single report by ID."""
        return self._reports.get(report_id)

    # ── Board / prefix constants ──────────────────────
    BOARD_PREFIXES: dict[str, str] = {
        "user": "USR-",
        "comms": "MSG-",
        "company": "REL-",
        "agent": "CYC-",
        "ops": "OPS-",
    }
    VALID_BOARDS = tuple(BOARD_PREFIXES.keys())

    # ── Kanban Board (FileStore — persistent) ─────────

    def suggest_kanban_task(
        self,
        suggested_by: str,
        title: str,
        description: str,
        category: str = "improvement",
        priority: str = "normal",
        assigned_to: str = "",
        board: str = "agent",
        domain: str = "vastian",
        tags: list[str] | None = None,
        sprint_id: str | None = None,
        sprint_points: int | None = None,
        blocked_by: list[str] | None = None,
        visibility: str = "normal",
        requires_approval: bool = True,
        user_id: str = "owner",
    ) -> str:
        """Agent suggests a task for the kanban board.

        New Wave 5 fields:
          board         — user | comms | company | agent (default: agent)
          domain        — domain/scenario tag (default: vastian)
          tags          — free-form tags
          sprint_id     — optional sprint this task belongs to
          sprint_points — story-point estimate
          blocked_by    — list of kanban_ids that block this task
          visibility    — normal | internal (internal = agent-only)
          requires_approval — if False, skip manager review (direct to suggested)

        ticket_id is auto-generated as {PREFIX}{short_id} (e.g. CYC-a1b2c3d4).
        """
        if board not in self.VALID_BOARDS:
            raise ValueError(
                f"Invalid board '{board}'. Valid: {', '.join(self.VALID_BOARDS)}. "
                "Choose ONE: company=Maria/business, user=user-facing, comms=meetings, agent=internal, ops=Orion/codebase."
            )
        kanban_id = uuid.uuid4().hex[:12]
        prefix = self.BOARD_PREFIXES.get(board, "CYC-")
        ticket_id = f"{prefix}{kanban_id}"
        now = datetime.now(UTC).isoformat()

        initial_status = "pending_review" if requires_approval else "suggested"

        record = {
            "kanban_id": kanban_id,
            "ticket_id": ticket_id,
            "board": board,
            "domain": domain,
            "user_id": user_id,
            "suggested_by": suggested_by,
            "title": title,
            "description": description,
            "category": category,
            "priority": priority,
            "status": initial_status,
            "assigned_to": assigned_to,
            "result": "",
            "user_notes": "",
            "tags": tags or [],
            "sprint_id": sprint_id,
            "sprint_points": sprint_points,
            "blocked_by": blocked_by or [],
            "visibility": visibility,
            "requires_approval": requires_approval,
            "created_at": now,
            "approved_at": None,
            "started_at": None,
            "completed_at": None,
            "reviewed_at": None,
        }
        self._kanban.insert(record)
        self._add_notification(
            None,
            suggested_by,
            f"Suggested task [{ticket_id}] (pending manager review): {title}",
            "info",
        )
        return kanban_id

    def get_kanban_tasks(
        self,
        status: str | None = None,
        board: str | None = None,
        domain: str | None = None,
        sprint_id: str | None = None,
    ) -> list[dict]:
        """Get kanban tasks with optional filters (status, board, domain, sprint)."""

        def _match(r: dict) -> bool:
            if status and r.get("status") != status:
                return False
            if board and r.get("board", "agent") != board:
                return False
            if domain and r.get("domain", "vastian") != domain:
                return False
            return not (sprint_id and r.get("sprint_id") != sprint_id)

        results = self._kanban.query(_match)
        results.sort(key=lambda r: r.get("created_at", ""), reverse=True)
        return results

    def manager_approve_kanban(self, kanban_id: str, manager_id: str, feedback: str = "") -> bool:
        """Manager approves a pending_review task — makes it visible to the user."""
        notes = f"[{manager_id}] {feedback}" if feedback else ""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "pending_review":
            return False
        self._kanban.update(kanban_id, {"status": "suggested", "user_notes": notes})
        return True

    def manager_reject_kanban(self, kanban_id: str, manager_id: str, feedback: str) -> bool:
        """Manager rejects a pending_review task — sends feedback to agent."""
        notes = f"[{manager_id} rejected] {feedback}"
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "pending_review":
            return False
        self._kanban.update(kanban_id, {"status": "rejected", "user_notes": notes})
        return True

    def submit_to_manager_qa(self, kanban_id: str) -> bool:
        """After agent completes work, submit result for manager quality review."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "in_progress":
            return False
        self._kanban.update(kanban_id, {"status": "pending_qa"})
        return True

    def manager_approve_qa(self, kanban_id: str, manager_id: str) -> bool:
        """Manager passes QA — result goes to user for final review."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "pending_qa":
            return False
        notes_prefix = f" [QA passed by {manager_id}]"
        current_notes = task.get("user_notes", "")
        self._kanban.update(
            kanban_id, {"status": "review", "user_notes": current_notes + notes_prefix}
        )
        return True

    def manager_reject_qa(self, kanban_id: str, manager_id: str, feedback: str) -> bool:
        """Manager fails QA — task goes back to approved for re-work."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "pending_qa":
            return False
        notes = f"[QA rejected by {manager_id}] {feedback}"
        self._kanban.update(kanban_id, {"status": "approved", "user_notes": notes, "result": ""})
        return True

    def approve_kanban_start(self, kanban_id: str) -> bool:
        """User approves a suggested task to begin work."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "suggested":
            return False
        now = datetime.now(UTC).isoformat()
        self._kanban.update(kanban_id, {"status": "approved", "approved_at": now})
        return True

    def assign_kanban_to_user(self, kanban_id: str) -> bool:
        """User claims a suggested task — approves it but assigns to 'user'.

        Tasks with assigned_to='user' are skipped by the agent execution loop
        and wait for user input via the inbox (task-<id>-yourfile.txt).
        """
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "suggested":
            return False
        now = datetime.now(UTC).isoformat()
        self._kanban.update(
            kanban_id,
            {
                "status": "approved",
                "approved_at": now,
                "assigned_to": "user",
            },
        )
        return True

    def start_kanban_task(self, kanban_id: str) -> bool:
        """Move an approved kanban task to in_progress."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "approved":
            return False
        now = datetime.now(UTC).isoformat()
        self._kanban.update(kanban_id, {"status": "in_progress", "started_at": now})
        return True

    def complete_kanban_task(self, kanban_id: str, result: str) -> bool:
        """Mark a kanban task as needing manager QA before user review."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "in_progress":
            return False
        now = datetime.now(UTC).isoformat()
        self._kanban.update(
            kanban_id, {"status": "pending_qa", "completed_at": now, "result": result[:5000]}
        )
        return True

    def approve_kanban_result(self, kanban_id: str) -> bool:
        """User approves the result — task moves to done."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "review":
            return False
        now = datetime.now(UTC).isoformat()
        self._kanban.update(kanban_id, {"status": "done", "reviewed_at": now})
        return True

    def reject_kanban_result(self, kanban_id: str, notes: str = "") -> bool:
        """User rejects the result — task goes back to approved for re-work."""
        task = self._kanban.get(kanban_id)
        if not task or task.get("status") != "review":
            return False
        self._kanban.update(kanban_id, {"status": "approved", "user_notes": notes, "result": ""})
        return True

    def update_kanban_status(self, kanban_id: str, status: str, **extra: str) -> bool:
        """Force-set a kanban task to any status (user override)."""
        task = self._kanban.get(kanban_id)
        if not task:
            return False
        now = datetime.now(UTC).isoformat()
        updates: dict = {"status": status}
        if status == "done":
            updates["reviewed_at"] = now
        elif status == "in_progress":
            updates["started_at"] = now
        elif status == "approved":
            updates["approved_at"] = now
        updates.update(extra)
        self._kanban.update(kanban_id, updates)
        return True

    def force_start_queued_task(self, task_id: str) -> bool:
        """Force-start a stuck queued background task."""
        assert self._conn is not None
        cur = self._conn.execute(
            "UPDATE background_tasks SET status='queued' WHERE task_id=? AND status IN ('queued','running')",
            (task_id,),
        )
        self._conn.commit()
        return cur.rowcount > 0

    def get_kanban_task(self, kanban_id: str) -> dict | None:
        return self._kanban.get(kanban_id)

    def get_completed_history(self, limit: int = 100) -> dict:
        """Get completed tasks and notifications for the history view."""
        assert self._conn is not None
        tasks = self._conn.execute(
            "SELECT * FROM background_tasks WHERE status IN ('completed','failed') ORDER BY completed_at DESC LIMIT ?",
            (limit,),
        ).fetchall()
        notifs = self._conn.execute(
            "SELECT * FROM notifications ORDER BY created_at DESC LIMIT ?",
            (limit,),
        ).fetchall()
        return {
            "tasks": [
                {
                    "task_id": r[0],
                    "task_type": r[1],
                    "agent_id": r[2],
                    "description": r[3],
                    "status": r[5],
                    "created_at": r[6],
                    "completed_at": r[8],
                    "result": r[9],
                }
                for r in tasks
            ],
            "notifications": [
                {
                    "id": r[0],
                    "task_id": r[1],
                    "agent_id": r[2],
                    "message": r[3],
                    "type": r[4],
                    "created_at": r[5],
                }
                for r in notifs
            ],
        }

    def get_sync_suggestions(self) -> list[dict]:
        """Get agent sync suggestions based on pending work and recent activity."""
        suggestions: list[dict] = []

        # Agents with pending kanban tasks (from FileStore)
        kanban_counts: dict[str, int] = {}
        for task in self._kanban.query(lambda r: r.get("status") in ("suggested", "review")):
            agent = task.get("suggested_by", "")
            kanban_counts[agent] = kanban_counts.get(agent, 0) + 1
        for agent_id, cnt in kanban_counts.items():
            suggestions.append(
                {
                    "agent_id": agent_id,
                    "reason": f"{cnt} task(s) awaiting your review on the kanban board",
                    "priority": "high",
                }
            )

        # Agents with running background tasks (from SQLite)
        assert self._conn is not None
        running = self._conn.execute(
            "SELECT agent_id, COUNT(*) as cnt FROM background_tasks WHERE status='running' GROUP BY agent_id"
        ).fetchall()
        for row in running:
            suggestions.append(
                {
                    "agent_id": row[0],
                    "reason": f"{row[1]} background task(s) still in progress",
                    "priority": "medium",
                }
            )

        # Recent failed tasks (from SQLite) — exclude post_chat_review (routine)
        # and tasks that have already been acknowledged/dismissed by the user.
        # Failed tasks older than 24 hours are auto-dismissed to prevent stale noise.
        failed = self._conn.execute(
            "SELECT task_id, agent_id, description, completed_at FROM background_tasks "
            "WHERE status='failed' AND task_type != 'post_chat_review' "
            "ORDER BY completed_at DESC LIMIT 10"
        ).fetchall()
        dismissed_raw = self.kv_get("dismissed_failed_tasks") or ""
        dismissed_ids = set(dismissed_raw.split(",")) if dismissed_raw else set()
        cutoff_24h = (datetime.now(UTC) - timedelta(hours=24)).isoformat()
        shown = 0
        for row in failed:
            task_id, agent_id, description, completed_at = row
            if task_id in dismissed_ids:
                continue
            # Auto-dismiss tasks older than 24 hours
            if completed_at and completed_at < cutoff_24h:
                dismissed_ids.add(task_id)
                continue
            suggestions.append(
                {
                    "agent_id": agent_id,
                    "reason": f"Failed task needs attention: {description[:60]}",
                    "priority": "high",
                    "task_id": task_id,
                }
            )
            shown += 1
            if shown >= 5:
                break
        # Persist auto-dismissed IDs
        if dismissed_raw != ",".join(dismissed_ids):
            self.kv_set("dismissed_failed_tasks", ",".join(dismissed_ids))

        return suggestions

    # ── Pending Approvals (SQLite — ephemeral) ──────

    def create_approval(
        self,
        agent_id: str,
        approval_type: str,
        description: str,
        payload: dict[str, Any] | None = None,
    ) -> str:
        """Create a pending approval (e.g. transaction). Returns approval_id."""
        assert self._conn is not None
        approval_id = uuid.uuid4().hex[:10]
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            """INSERT INTO pending_approvals
               (approval_id, agent_id, approval_type, description, payload, status, created_at)
               VALUES (?,?,?,?,?,?,?)""",
            (
                approval_id,
                agent_id,
                approval_type,
                description,
                json.dumps(payload or {}),
                "pending",
                now,
            ),
        )
        self._conn.commit()
        self._add_notification(
            None,
            agent_id,
            f"Pending approval: {description[:80]} — /approve {approval_id}",
            "approval",
        )
        return approval_id

    def get_pending_approvals(self) -> list[dict[str, Any]]:
        """Get all pending approvals."""
        assert self._conn is not None
        rows = self._conn.execute(
            "SELECT * FROM pending_approvals WHERE status='pending' ORDER BY created_at DESC",
        ).fetchall()
        return [
            {
                "approval_id": r[0],
                "agent_id": r[1],
                "approval_type": r[2],
                "description": r[3],
                "payload": json.loads(r[4]) if r[4] else {},
                "status": r[5],
                "created_at": r[6],
            }
            for r in rows
        ]

    def resolve_approval(self, approval_id: str, approved: bool) -> dict[str, Any] | None:
        """Approve or deny a pending approval. Returns the approval record or None."""
        assert self._conn is not None
        row = self._conn.execute(
            "SELECT * FROM pending_approvals WHERE approval_id=? AND status='pending'",
            (approval_id,),
        ).fetchone()
        if not row:
            return None
        new_status = "approved" if approved else "denied"
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "UPDATE pending_approvals SET status=?, resolved_at=? WHERE approval_id=?",
            (new_status, now, approval_id),
        )
        self._conn.commit()
        record = {
            "approval_id": row[0],
            "agent_id": row[1],
            "approval_type": row[2],
            "description": row[3],
            "payload": json.loads(row[4]) if row[4] else {},
            "status": new_status,
            "created_at": row[6],
        }
        self._add_notification(
            None,
            row[1],
            f"Approval {new_status}: {row[3][:60]}",
            "completed" if approved else "failed",
        )
        return record

    # ── Outreach Requests (SQLite — ephemeral) ────────

    def create_outreach(
        self,
        agent_id: str,
        subject: str,
        message: str,
        priority: str = "normal",
        scheduled_at: str | None = None,
    ) -> str:
        """Agent requests user input. Returns outreach_id.

        If *scheduled_at* is provided (ISO-8601 datetime or YYYY-MM-DD date),
        the outreach stays hidden until that time arrives.
        """
        assert self._conn is not None
        outreach_id = uuid.uuid4().hex[:10]
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            """INSERT INTO outreach_requests
               (outreach_id, agent_id, subject, message, priority, status, created_at, scheduled_at)
               VALUES (?,?,?,?,?,?,?,?)""",
            (outreach_id, agent_id, subject, message, priority, "pending", now, scheduled_at),
        )
        self._conn.commit()
        return outreach_id

    def schedule_outreach(
        self,
        agent_id: str,
        subject: str,
        message: str,
        scheduled_date: str,
        priority: str = "normal",
    ) -> str:
        """Schedule an outreach for a future date. Returns outreach_id.

        *scheduled_date* can be YYYY-MM-DD (delivers at start of that day) or
        a full ISO datetime.  The delivery loop will hold it until then.
        """
        # Normalize bare dates to midnight UTC
        if len(scheduled_date) == 10:  # YYYY-MM-DD
            scheduled_date = f"{scheduled_date}T00:00:00+00:00"
        return self.create_outreach(
            agent_id=agent_id,
            subject=subject,
            message=message,
            priority=priority,
            scheduled_at=scheduled_date,
        )

    def get_pending_outreach(
        self, priority_filter: str | None = None, include_scheduled: bool = False
    ) -> list[dict[str, Any]]:
        """Get pending outreach requests, ordered by priority then creation time.

        By default, outreach with a future ``scheduled_at`` is excluded.
        Pass *include_scheduled=True* to see everything (for the /check-in display).

        Priority ordering: critical > high > normal > low
        """
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        query = "SELECT * FROM outreach_requests WHERE status='pending'"
        if not include_scheduled:
            query += f" AND (scheduled_at IS NULL OR scheduled_at <= '{now}')"
        if priority_filter:
            query += f" AND priority='{priority_filter}'"
        query += """
            ORDER BY
                CASE priority
                    WHEN 'critical' THEN 0
                    WHEN 'high' THEN 1
                    WHEN 'normal' THEN 2
                    WHEN 'low' THEN 3
                    ELSE 4
                END,
                created_at ASC
        """
        rows = self._conn.execute(query).fetchall()
        return [
            {
                "outreach_id": r[0],
                "agent_id": r[1],
                "subject": r[2],
                "message": r[3],
                "priority": r[4],
                "status": r[5],
                "created_at": r[6],
                "scheduled_at": r[9] if len(r) > 9 else None,
            }
            for r in rows
        ]

    def get_scheduled_outreach(self) -> list[dict[str, Any]]:
        """Get all pending outreach that is scheduled for the future."""
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        rows = self._conn.execute(
            "SELECT * FROM outreach_requests WHERE status='pending' AND scheduled_at > ? ORDER BY scheduled_at ASC",
            (now,),
        ).fetchall()
        return [
            {
                "outreach_id": r[0],
                "agent_id": r[1],
                "subject": r[2],
                "message": r[3],
                "priority": r[4],
                "status": r[5],
                "created_at": r[6],
                "scheduled_at": r[9] if len(r) > 9 else None,
            }
            for r in rows
        ]

    def mark_outreach_delivered(self, outreach_id: str) -> None:
        """Mark an outreach request as delivered to the user."""
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "UPDATE outreach_requests SET status='delivered', delivered_at=? WHERE outreach_id=?",
            (now, outreach_id),
        )
        self._conn.commit()

    def resolve_outreach(self, outreach_id: str, response: str) -> None:
        """Mark an outreach request as responded to."""
        assert self._conn is not None
        self._conn.execute(
            "UPDATE outreach_requests SET status='responded', response=? WHERE outreach_id=?",
            (response[:2000], outreach_id),
        )
        self._conn.commit()

    def dismiss_outreach(self, outreach_id: str) -> None:
        """Dismiss an outreach request without responding."""
        assert self._conn is not None
        self._conn.execute(
            "UPDATE outreach_requests SET status='dismissed' WHERE outreach_id=?",
            (outreach_id,),
        )
        self._conn.commit()

    def snooze_outreach(self, outreach_id: str, hours: int = 4) -> str | None:
        """Snooze an outreach — reschedule it for *hours* from now.

        Returns the new outreach_id if rescheduled, or None if the original
        was not found. The original is dismissed and a new copy is created
        with the future ``scheduled_at``.
        """
        assert self._conn is not None
        row = self._conn.execute(
            "SELECT agent_id, subject, message, priority FROM outreach_requests WHERE outreach_id=?",
            (outreach_id,),
        ).fetchone()
        if not row:
            return None

        # Dismiss the original
        self.dismiss_outreach(outreach_id)

        # Create a rescheduled copy
        from datetime import timedelta

        future = (datetime.now(UTC) + timedelta(hours=hours)).isoformat()
        return self.create_outreach(
            agent_id=row[0],
            subject=row[1],
            message=row[2],
            priority=row[3],
            scheduled_at=future,
        )

    def get_outreach_count(self) -> int:
        """Count pending outreach requests that are ready for delivery.

        Excludes future-scheduled items — those show under 'Upcoming Reminders'
        and should not inflate the 'N agents need you' prompt indicator.
        """
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        row = self._conn.execute(
            "SELECT COUNT(*) FROM outreach_requests WHERE status='pending'"
            " AND (scheduled_at IS NULL OR scheduled_at <= ?)",
            (now,),
        ).fetchone()
        return row[0] if row else 0

    def get_outreach_breakdown(self) -> dict[str, int]:
        """Return a breakdown of pending outreach: unique agent count and total alerts.

        Returns dict with keys: ``agents`` (unique agents needing user),
        ``alerts`` (total pending outreach items).
        """
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        row = self._conn.execute(
            "SELECT COUNT(DISTINCT agent_id), COUNT(*) FROM outreach_requests "
            "WHERE status='pending' AND (scheduled_at IS NULL OR scheduled_at <= ?)",
            (now,),
        ).fetchone()
        return {"agents": row[0] if row else 0, "alerts": row[1] if row else 0}

    # ── Key-Value Store (persistent timers, config) ─────

    def kv_get(self, key: str) -> str | None:
        """Get a value from the persistent key-value store."""
        assert self._conn is not None
        row = self._conn.execute("SELECT value FROM kv_store WHERE key=?", (key,)).fetchone()
        return row[0] if row else None

    def kv_set(self, key: str, value: str) -> None:
        """Set a value in the persistent key-value store."""
        assert self._conn is not None
        self._conn.execute(
            "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)",
            (key, value),
        )
        self._conn.commit()

    def set_kv(self, key: str, value: str) -> None:
        """Alias for kv_set (used by orchestrator loops)."""
        self.kv_set(key, value)

    def dismiss_failed_task(self, task_id: str) -> bool:
        """Dismiss a failed task so it no longer appears in sync suggestions."""
        dismissed_raw = self.kv_get("dismissed_failed_tasks") or ""
        dismissed_ids = set(dismissed_raw.split(",")) if dismissed_raw else set()
        dismissed_ids.add(task_id)
        dismissed_ids.discard("")  # clean up empty strings
        self.kv_set("dismissed_failed_tasks", ",".join(dismissed_ids))
        return True

    def dismiss_all_failed_tasks(self) -> int:
        """Dismiss all currently failed tasks from sync suggestions."""
        assert self._conn is not None
        failed = self._conn.execute(
            "SELECT task_id FROM background_tasks WHERE status='failed'"
        ).fetchall()
        dismissed_raw = self.kv_get("dismissed_failed_tasks") or ""
        dismissed_ids = set(dismissed_raw.split(",")) if dismissed_raw else set()
        count = 0
        for (task_id,) in failed:
            if task_id not in dismissed_ids:
                dismissed_ids.add(task_id)
                count += 1
        dismissed_ids.discard("")
        self.kv_set("dismissed_failed_tasks", ",".join(dismissed_ids))
        return count

    def get_loop_last_run(self, loop_name: str) -> str | None:
        """Get the last run timestamp for a background loop."""
        return self.kv_get(f"loop_last_run:{loop_name}")

    def set_loop_last_run(self, loop_name: str) -> None:
        """Record that a background loop just ran."""
        self.kv_set(f"loop_last_run:{loop_name}", datetime.now(UTC).isoformat())

    def seconds_since_loop_ran(self, loop_name: str) -> float | None:
        """How many seconds since a loop last ran. None if never ran."""
        last = self.get_loop_last_run(loop_name)
        if not last:
            return None
        try:
            last_dt = datetime.fromisoformat(last)
            return (datetime.now(UTC) - last_dt).total_seconds()
        except Exception:
            return None

    # ── Sprint Management (FileStore — persistent) ──────

    def _load_sprints(self) -> list[dict]:
        """Load sprints from state/sprints.json."""
        if not hasattr(self, "_sprints_store"):
            sd = self._kanban.path.parent  # state/ dir
            self._sprints_store = FileStore(sd / "sprints.json", id_field="sprint_id")
        return self._sprints_store.load_all()

    def create_sprint(
        self,
        name: str,
        goal: str = "",
        board: str | None = None,
    ) -> str:
        """Create a new sprint. Returns sprint_id."""
        if not hasattr(self, "_sprints_store"):
            sd = self._kanban.path.parent
            self._sprints_store = FileStore(sd / "sprints.json", id_field="sprint_id")

        sprint_id = f"sprint-{uuid.uuid4().hex[:8]}"
        now = datetime.now(UTC).isoformat()
        record = {
            "sprint_id": sprint_id,
            "name": name,
            "goal": goal,
            "board": board,
            "status": "active",
            "started_at": now,
            "closed_at": None,
            "ticket_ids": [],
        }
        self._sprints_store.insert(record)
        logger.info("Sprint created: %s — %s", sprint_id, name)
        return sprint_id

    def close_sprint(self, sprint_id: str) -> bool:
        """Close an active sprint."""
        if not hasattr(self, "_sprints_store"):
            return False
        sprint = self._sprints_store.get(sprint_id)
        if not sprint or sprint.get("status") != "active":
            return False
        now = datetime.now(UTC).isoformat()
        self._sprints_store.update(sprint_id, {"status": "closed", "closed_at": now})
        return True

    def assign_ticket_to_sprint(self, kanban_id: str, sprint_id: str) -> bool:
        """Assign a kanban task to a sprint."""
        if not hasattr(self, "_sprints_store"):
            return False
        sprint = self._sprints_store.get(sprint_id)
        if not sprint or sprint.get("status") != "active":
            return False
        ticket_ids = sprint.get("ticket_ids", [])
        if kanban_id not in ticket_ids:
            ticket_ids.append(kanban_id)
            self._sprints_store.update(sprint_id, {"ticket_ids": ticket_ids})
        # Also update the kanban task's sprint_id
        task = self._kanban.get(kanban_id)
        if task:
            self._kanban.update(kanban_id, {"sprint_id": sprint_id})
        return True

    def get_sprints(self, status: str | None = None) -> list[dict]:
        """Get all sprints, optionally filtered by status."""
        sprints = self._load_sprints()
        if status:
            sprints = [s for s in sprints if s.get("status") == status]
        sprints.sort(key=lambda s: s.get("started_at", ""), reverse=True)
        return sprints

    def get_sprint_burndown(self, sprint_id: str) -> dict:
        """Get a summary of ticket statuses for a sprint (burndown data)."""
        tasks = self.get_kanban_tasks(sprint_id=sprint_id)
        status_counts: dict[str, int] = {}
        total_points = 0
        done_points = 0
        for t in tasks:
            st = t.get("status", "unknown")
            status_counts[st] = status_counts.get(st, 0) + 1
            pts = t.get("sprint_points") or 0
            total_points += pts
            if st in ("done", "archived"):
                done_points += pts
        return {
            "sprint_id": sprint_id,
            "total_tickets": len(tasks),
            "status_counts": status_counts,
            "total_points": total_points,
            "done_points": done_points,
        }

    # ── Suggestions (agent → user approval flow) ──────────────────

    SUGGESTION_TYPES = {
        "activity",
        "challenge",
        "council",
        "mentor",
        "plan",
        "scenario",
        "roleplay",
        "real_coach",
    }

    def create_suggestion(
        self,
        suggestion_type: str,
        agent_id: str,
        title: str,
        description: str = "",
        coach_type: str = "",
        payload: dict | None = None,
        user_id: str = "owner",
    ) -> str:
        """Create a new suggestion from an agent for user approval."""
        assert self._conn is not None
        if suggestion_type not in self.SUGGESTION_TYPES:
            raise ValueError(f"Invalid suggestion type: {suggestion_type}")
        suggestion_id = f"sug-{uuid.uuid4().hex[:10]}"
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "INSERT INTO suggestions (id, type, agent_id, title, description, coach_type, payload_json, status, created_at, user_id) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
            (
                suggestion_id,
                suggestion_type,
                agent_id,
                title,
                description,
                coach_type,
                json.dumps(payload or {}),
                now,
                user_id,
            ),
        )
        self._conn.commit()
        logger.info("Suggestion created: %s [%s] by %s", suggestion_id, suggestion_type, agent_id)
        return suggestion_id

    def get_suggestions(
        self,
        status: str = "pending",
        suggestion_type: str | None = None,
        user_id: str = "owner",
    ) -> list[dict]:
        """Get suggestions filtered by status and optionally by type."""
        assert self._conn is not None
        query = "SELECT * FROM suggestions WHERE status = ? AND user_id = ?"
        params: list = [status, user_id]
        if suggestion_type:
            query += " AND type = ?"
            params.append(suggestion_type)
        query += " ORDER BY created_at DESC"
        cur = self._conn.execute(query, params)
        cols = [d[0] for d in cur.description]
        return [dict(zip(cols, row, strict=False)) for row in cur.fetchall()]

    def get_suggestion_counts(self, user_id: str = "owner") -> dict:
        """Get pending suggestion counts by category for badge display."""
        assert self._conn is not None
        cur = self._conn.execute(
            "SELECT type, COUNT(*) FROM suggestions WHERE status = 'pending' AND user_id = ? GROUP BY type",
            (user_id,),
        )
        counts: dict[str, int] = {}
        for row in cur.fetchall():
            counts[row[0]] = row[1]
        # Aggregate into badge categories
        bell_types = {"roleplay", "real_coach"}
        return {
            "personas": sum(counts.get(t, 0) for t in ("activity", "challenge")),
            "councils": sum(counts.get(t, 0) for t in ("council", "mentor")),
            "plans": counts.get("plan", 0),
            "scenarios": counts.get("scenario", 0),
            "chat": sum(counts.get(t, 0) for t in bell_types),
            "total": sum(counts.values()),
            "by_type": counts,
        }

    def resolve_suggestion(self, suggestion_id: str, action: str) -> bool:
        """Accept or reject a suggestion."""
        assert self._conn is not None
        if action not in ("accepted", "rejected"):
            return False
        now = datetime.now(UTC).isoformat()
        cur = self._conn.execute(
            "UPDATE suggestions SET status = ?, resolved_at = ? WHERE id = ? AND status = 'pending'",
            (action, now, suggestion_id),
        )
        self._conn.commit()
        return cur.rowcount > 0

    # ── Token usage tracking ─────────────────────────────────────

    def record_token_usage(
        self,
        agent_id: str,
        provider: str,
        model: str,
        input_tokens: int,
        output_tokens: int,
        user_id: str = "owner",
    ) -> None:
        """Record token usage for an LLM call."""
        assert self._conn is not None
        now = datetime.now(UTC).isoformat()
        self._conn.execute(
            "INSERT INTO token_usage (agent_id, user_id, provider, model, input_tokens, output_tokens, timestamp) "
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            (agent_id, user_id, provider, model, input_tokens, output_tokens, now),
        )
        self._conn.commit()

    def get_token_usage(
        self,
        agent_id: str | None = None,
        user_id: str | None = None,
        since: str | None = None,
    ) -> list[dict]:
        """Get token usage records with optional filters."""
        assert self._conn is not None
        query = "SELECT agent_id, provider, model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COUNT(*) as calls FROM token_usage WHERE 1=1"
        params: list = []
        if agent_id:
            query += " AND agent_id = ?"
            params.append(agent_id)
        if user_id:
            query += " AND user_id = ?"
            params.append(user_id)
        if since:
            query += " AND timestamp >= ?"
            params.append(since)
        query += (
            " GROUP BY agent_id, provider, model ORDER BY SUM(input_tokens + output_tokens) DESC"
        )
        cur = self._conn.execute(query, params)
        cols = [d[0] for d in cur.description]
        return [dict(zip(cols, row, strict=False)) for row in cur.fetchall()]

    def close(self) -> None:
        if self._conn:
            self._conn.close()
            self._conn = None
