"""Cost control — track LLM token usage, enforce budgets, alert on limits.

Tracks:
- Tokens per conversation, task, and day (including cache read/write)
- Estimated cost based on provider pricing (YAML-driven, Rule #12)
- Configurable daily/monthly budgets
- Pre-flight budget gate via check_budget_or_raise()

Pricing loaded from defaults/skills/_meta/model-routing/config/pricing.yaml
with vault override support.  Cache-aware: Anthropic prompt caching (~90%
discount on cached input) and OpenAI auto-caching (~75% discount) are
reflected in real cost estimates.

No agent loop should be able to burn unlimited money.
"""

from __future__ import annotations

import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import aiosqlite

# ── provider / mode derivation ────────────────────────────────────────────
# Kept here (not YAML) because the mapping is structural: it decodes LiteLLM
# model-ID conventions, not a business policy. Users who add a new provider
# get an "unknown/cloud" row in the exposure log, not a missing row.

_LOCAL_PROVIDERS = frozenset({"ollama", "lmstudio", "localai", "local"})  # arch:deterministic — LiteLLM provider-ID convention, not user policy


def _derive_provider(model: str) -> str:  # arch:deterministic
    """Extract provider name from a LiteLLM-style model string.

    Conventions:
        "anthropic/claude-sonnet-4..."  -> "anthropic"
        "openai/gpt-5.4"                -> "openai"
        "ollama/qwen2.5:14b"            -> "ollama"
        "ollama_chat/llama3"            -> "ollama"
        "claude-sonnet-4..." (bare)     -> "anthropic" (known-name heuristic)
        "gpt-5.4" (bare)                -> "openai"    (known-name heuristic)
        <unknown bare>                  -> "unknown"
    """
    if not model:
        return "unknown"
    model = model.strip().lower()
    if "/" in model:
        head = model.split("/", 1)[0]
        # Normalize `ollama_chat` and other underscore variants
        if head.startswith("ollama"):
            return "ollama"
        return head or "unknown"
    # Bare name — best-effort guess from the most common families
    if model.startswith("claude"):
        return "anthropic"
    if model.startswith("gpt") or model.startswith("o1") or model.startswith("o3"):
        return "openai"
    if model.startswith("gemini"):
        return "google"
    if model.startswith("mistral"):
        return "mistral"
    return "unknown"


def _derive_mode(provider: str) -> str:  # arch:deterministic
    """Return `local` when data never leaves the machine; `cloud` otherwise."""
    return "local" if provider in _LOCAL_PROVIDERS else "cloud"

log = logging.getLogger(__name__)

# ── pricing loader ───────────────────────────────────────────────────────

# Hardcoded fallback — used ONLY when YAML is missing (first boot, broken install).
_FALLBACK_PRICING: dict[str, dict[str, float]] = {  # arch:deterministic — safety net
    "claude-sonnet-4-20250514": {"input": 3.0, "output": 15.0},
    "gpt-5.4": {"input": 2.5, "output": 15.0},
    "gpt-4o": {"input": 2.5, "output": 10.0},
    "ollama": {"input": 0, "output": 0},
    "_default": {"input": 3.0, "output": 15.0},
}

# Module-level cache — loaded once, reloaded on set_pricing().
_pricing: dict[str, dict[str, float]] = {}


def _load_pricing_yaml(skill_loader: Any = None) -> dict[str, dict[str, float]]:
    """Load pricing from YAML via skill config resolution (vault first, then defaults).

    Falls back to direct file read if no skill_loader is available (tests, early boot).
    """
    pricing: dict[str, dict[str, float]] = {}

    # Try skill loader first (standard resolution: vault override → bundled default)
    if skill_loader:
        try:
            data = skill_loader.get_skill_config("model-routing", "pricing")
            if data and isinstance(data, dict):
                pricing = {str(k): v for k, v in data.items() if isinstance(v, dict)}
        except Exception:
            pass

    # Direct file fallback (no skill loader, or skill loader returned nothing)
    if not pricing:
        try:
            import yaml
            # parents: [0]=infra, [1]=agntspace, [2]=src, [3]=server, [4]=repo root
            repo_root = Path(__file__).resolve().parents[4]
            candidates = [
                repo_root / "defaults" / "skills" / "_meta" / "model-routing" / "config" / "pricing.yaml",
            ]
            # Also check importlib resources path (installed package)
            try:
                import importlib.resources
                pkg = importlib.resources.files("agntspace") / "defaults" / "skills" / "_meta" / "model-routing" / "config" / "pricing.yaml"
                candidates.append(Path(str(pkg)))
            except Exception:
                pass

            for p in candidates:
                if p.exists():
                    with open(p) as f:
                        data = yaml.safe_load(f)
                    if data and isinstance(data, dict):
                        pricing = {str(k): v for k, v in data.items() if isinstance(v, dict)}
                    break
        except Exception as exc:
            log.warning("Failed to load pricing.yaml: %s — using fallback", exc)

    if not pricing:
        log.info("No pricing.yaml found — using hardcoded fallback pricing")
        pricing = dict(_FALLBACK_PRICING)

    return pricing


def get_pricing() -> dict[str, dict[str, float]]:
    """Return current pricing table (module-level cache)."""
    global _pricing
    if not _pricing:
        _pricing = _load_pricing_yaml()
    return _pricing


def set_pricing(pricing: dict[str, dict[str, float]] | None = None, skill_loader: Any = None) -> None:
    """Replace the pricing cache.  Called from main.py after skill_loader is ready."""
    global _pricing
    if pricing:
        _pricing = pricing
    else:
        _pricing = _load_pricing_yaml(skill_loader)
    log.info("Pricing loaded: %d models", len(_pricing))


class BudgetExceededError(Exception):
    """Raised when budget is exceeded and enforcement is enabled."""

    def __init__(self, message: str, daily_cost: float = 0, monthly_cost: float = 0):
        self.daily_cost = daily_cost
        self.monthly_cost = monthly_cost
        super().__init__(message)


class CostTracker:
    """Tracks LLM token usage and enforces budget limits."""

    def __init__(
        self,
        db: aiosqlite.Connection,
        daily_budget: float = 10.0,
        monthly_budget: float = 100.0,
        enforce_budget: bool = True,
    ) -> None:
        self._db = db
        self._daily_budget = daily_budget
        self._monthly_budget = monthly_budget
        self._enforce_budget = enforce_budget
        # Budget-exceeded throttling: log WARNING on transition into the
        # exceeded state, then downgrade subsequent log lines to DEBUG so
        # overrun days don't fill the log with identical warnings.
        self._daily_exceeded_logged: str | None = None   # date string, or None
        self._monthly_exceeded_logged: str | None = None  # YYYY-MM, or None

    async def init_schema(self) -> None:
        """Create cost tracking tables + run migrations."""
        await self._db.executescript("""
            CREATE TABLE IF NOT EXISTS cost_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                model TEXT NOT NULL,
                input_tokens INTEGER NOT NULL,
                output_tokens INTEGER NOT NULL,
                estimated_cost REAL NOT NULL,
                conversation_id TEXT,
                action TEXT
            );
            CREATE INDEX IF NOT EXISTS idx_cost_timestamp ON cost_log(timestamp);
        """)
        # Migration: add cache token columns (idempotent)
        for col, default in [
            ("cache_read_tokens", 0),
            ("cache_write_tokens", 0),
        ]:
            try:
                await self._db.execute(
                    f"ALTER TABLE cost_log ADD COLUMN {col} INTEGER NOT NULL DEFAULT {default}"
                )
            except Exception:
                pass  # column already exists
        # Migration: add goal_id column for per-goal budget tracking
        try:
            await self._db.execute(
                "ALTER TABLE cost_log ADD COLUMN goal_id TEXT DEFAULT NULL"
            )
        except Exception:
            pass  # column already exists
        try:
            await self._db.execute(
                "CREATE INDEX IF NOT EXISTS idx_cost_goal ON cost_log(goal_id)"
            )
        except Exception:
            pass

        # Migration (§9.5 cloud exposure dashboard): per-call provenance.
        # Pre-migration rows default to empty strings / `[]` so aggregate queries
        # still work — but they group under "Unknown" until new data accumulates.
        _exposure_cols = [
            ("provider", "TEXT DEFAULT ''"),
            ("mode", "TEXT DEFAULT ''"),
            ("caller", "TEXT DEFAULT ''"),
            ("correlation_id", "TEXT DEFAULT ''"),
            ("data_categories", "TEXT DEFAULT '[]'"),
        ]
        for col, coltype in _exposure_cols:
            try:
                await self._db.execute(
                    f"ALTER TABLE cost_log ADD COLUMN {col} {coltype}"
                )
            except Exception:
                pass  # column already exists
        for idx_sql in (
            "CREATE INDEX IF NOT EXISTS idx_cost_provider ON cost_log(provider)",
            "CREATE INDEX IF NOT EXISTS idx_cost_caller ON cost_log(caller)",
            "CREATE INDEX IF NOT EXISTS idx_cost_mode ON cost_log(mode)",
            "CREATE INDEX IF NOT EXISTS idx_cost_correlation ON cost_log(correlation_id)",
        ):
            try:
                await self._db.execute(idx_sql)
            except Exception:
                pass
        await self._db.commit()

    async def record(
        self,
        model: str,
        input_tokens: int,
        output_tokens: int,
        conversation_id: str | None = None,
        action: str | None = None,
        cache_read_tokens: int = 0,
        cache_write_tokens: int = 0,
        provider_cost: float | None = None,
        goal_id: str | None = None,
        caller: str | None = None,
        data_categories: list[str] | None = None,
        correlation_id: str | None = None,
    ) -> dict:
        """Record token usage and check budget.

        When provider_cost is set (from LiteLLM's built-in cost calculator),
        it is used as-is — more accurate than any YAML because LiteLLM tracks
        price changes, tiered pricing, and provider-specific discounts.
        Falls back to YAML-based estimate when provider_cost is None.

        Cloud-exposure fields (§9.5):
            caller          — pipeline that triggered the call (e.g. "chat",
                              "daily-briefing", "wq-ingest"). If None, derived
                              from the current correlation_id in context.
            data_categories — free-form list of what data was in the prompt
                              (e.g. ["identity", "journal_today", "memory"]).
                              Callers that know what they sent should populate
                              this; otherwise it stays empty and the dashboard
                              falls back to caller-based inference.
            correlation_id  — causal chain this call belongs to. If None,
                              read from the current correlation_id contextvar.

        Returns dict with cost info and whether budget is exceeded.
        """
        if provider_cost is not None and provider_cost > 0:
            cost = provider_cost
        else:
            cost = self._estimate_cost(
                model, input_tokens, output_tokens,
                cache_read_tokens, cache_write_tokens,
            )
        now = datetime.now(UTC).isoformat()

        # Exposure metadata — auto-derive from context if not explicit.
        if correlation_id is None:
            try:
                from agntspace.activity.decisions import current_correlation_id
                correlation_id = current_correlation_id() or ""
            except Exception:
                correlation_id = ""
        if caller is None:
            try:
                from agntspace.activity.decisions import caller_from_correlation_id
                caller = caller_from_correlation_id(correlation_id or "")
            except Exception:
                caller = ""
        provider = _derive_provider(model)
        mode = _derive_mode(provider)
        categories_json = json.dumps(data_categories or [])

        await self._db.execute(
            "INSERT INTO cost_log (timestamp, model, input_tokens, output_tokens, "
            "cache_read_tokens, cache_write_tokens, estimated_cost, conversation_id, "
            "action, goal_id, provider, mode, caller, correlation_id, data_categories) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (now, model, input_tokens, output_tokens,
             cache_read_tokens, cache_write_tokens, cost, conversation_id,
             action, goal_id, provider, mode, caller or "",
             correlation_id or "", categories_json),
        )
        await self._db.commit()

        # Check budgets
        daily = await self.get_daily_cost()
        monthly = await self.get_monthly_cost()

        budget_exceeded = False
        warning = None

        from datetime import datetime as _dt
        today = _dt.now().strftime("%Y-%m-%d")
        this_month = _dt.now().strftime("%Y-%m")

        if self._daily_budget > 0 and daily >= self._daily_budget:
            budget_exceeded = True
            warning = f"Daily budget exceeded: ${daily:.2f} / ${self._daily_budget:.2f}"
            if self._daily_exceeded_logged != today:
                log.warning(warning)
                self._daily_exceeded_logged = today
            else:
                log.debug(warning)

        if self._monthly_budget > 0 and monthly >= self._monthly_budget:
            budget_exceeded = True
            warning = f"Monthly budget exceeded: ${monthly:.2f} / ${self._monthly_budget:.2f}"
            if self._monthly_exceeded_logged != this_month:
                log.warning(warning)
                self._monthly_exceeded_logged = this_month
            else:
                log.debug(warning)

        # Alert at 80%
        if not warning and self._daily_budget > 0 and daily >= self._daily_budget * 0.8:
            warning = f"Approaching daily budget: ${daily:.2f} / ${self._daily_budget:.2f}"

        return {
            "cost": cost,
            "daily_total": daily,
            "monthly_total": monthly,
            "budget_exceeded": budget_exceeded,
            "warning": warning,
        }

    # ── §9.5 cloud-exposure surface ──────────────────────────────────────
    #
    # Every agent action is already recorded in the decision log (what + why);
    # every LLM call is already in the cost log (how many tokens). These
    # helpers answer the third question users deserve the answer to: WHAT
    # left my machine, WHERE did it go, and through which pipeline.
    #
    # None of these methods write. Pure read surface over the existing log.

    async def get_exposure_summary(
        self,
        since_iso: str | None = None,
        until_iso: str | None = None,
        include_local: bool = False,
    ) -> dict:
        """Aggregate cloud-LLM exposure over a time window.

        Args:
            since_iso, until_iso: ISO-8601 timestamps bounding the window.
                `since_iso=None` → 30 days ago (default privacy-dashboard view).
                `until_iso=None` → now.
            include_local: if False (default), local-mode calls (Ollama et al.)
                are excluded entirely because they never leave the machine.
                A dashboard that wants a "total LLM spend" view can flip it on.

        Returns:
            {
                "window":  {"since": iso, "until": iso, "include_local": bool},
                "totals":  {cloud_calls, cloud_input_tokens, cloud_output_tokens,
                            cloud_cached_tokens, cloud_cost, local_calls,
                            local_input_tokens, local_output_tokens},
                "by_provider": [{"provider", "mode", "calls", "input_tokens",
                                 "output_tokens", "cached_tokens", "cost"}...],
                "by_caller":   [{"caller", "calls", "input_tokens", "output_tokens",
                                 "cost", "providers": [provider, ...]}...],
                "by_model":    [{"model", "provider", "mode", "calls",
                                 "input_tokens", "output_tokens", "cost"}...],
            }

        Sorted: providers by calls DESC, callers by input_tokens DESC.
        """
        from datetime import timedelta

        if since_iso is None:
            since_iso = (datetime.now(UTC) - timedelta(days=30)).isoformat()
        if until_iso is None:
            until_iso = datetime.now(UTC).isoformat()

        where = "WHERE timestamp >= ? AND timestamp <= ?"
        params: list[Any] = [since_iso, until_iso]
        if not include_local:
            where += " AND (mode != ? OR mode IS NULL)"
            params.append("local")

        # Totals (two rows: one for cloud, one for local — or just cloud if
        # include_local=False).
        cursor = await self._db.execute(
            f"SELECT COALESCE(mode, '') as m, COUNT(*), "
            f"COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), "
            f"COALESCE(SUM(cache_read_tokens + cache_write_tokens), 0), "
            f"COALESCE(SUM(estimated_cost), 0.0) "
            f"FROM cost_log {where} GROUP BY m",
            params,
        )
        total_rows = await cursor.fetchall()
        totals = {
            "cloud_calls": 0, "cloud_input_tokens": 0, "cloud_output_tokens": 0,
            "cloud_cached_tokens": 0, "cloud_cost": 0.0,
            "local_calls": 0, "local_input_tokens": 0, "local_output_tokens": 0,
        }
        for row in total_rows:
            m = (row[0] or "").lower()
            if m == "local":
                totals["local_calls"] += int(row[1])
                totals["local_input_tokens"] += int(row[2])
                totals["local_output_tokens"] += int(row[3])
            else:
                totals["cloud_calls"] += int(row[1])
                totals["cloud_input_tokens"] += int(row[2])
                totals["cloud_output_tokens"] += int(row[3])
                totals["cloud_cached_tokens"] += int(row[4])
                totals["cloud_cost"] += float(row[5])
        totals["cloud_cost"] = round(totals["cloud_cost"], 6)

        # By provider
        cursor = await self._db.execute(
            f"SELECT COALESCE(provider, 'unknown'), COALESCE(mode, ''), COUNT(*), "
            f"COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), "
            f"COALESCE(SUM(cache_read_tokens + cache_write_tokens), 0), "
            f"COALESCE(SUM(estimated_cost), 0.0) "
            f"FROM cost_log {where} "
            f"GROUP BY COALESCE(provider, 'unknown'), COALESCE(mode, '') "
            f"ORDER BY COUNT(*) DESC",
            params,
        )
        by_provider = [
            {
                "provider": row[0] or "unknown",
                "mode": row[1] or "unknown",
                "calls": int(row[2]),
                "input_tokens": int(row[3]),
                "output_tokens": int(row[4]),
                "cached_tokens": int(row[5]),
                "cost": round(float(row[6]), 6),
            }
            for row in await cursor.fetchall()
        ]

        # By caller
        cursor = await self._db.execute(
            f"SELECT COALESCE(caller, ''), COUNT(*), "
            f"COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), "
            f"COALESCE(SUM(estimated_cost), 0.0) "
            f"FROM cost_log {where} "
            f"GROUP BY COALESCE(caller, '') "
            f"ORDER BY SUM(input_tokens) DESC, COUNT(*) DESC",
            params,
        )
        caller_rows = await cursor.fetchall()

        # Secondary query: providers per caller (avoids an SQLite GROUP_CONCAT
        # dependency and keeps the result shape deterministic).
        cursor = await self._db.execute(
            f"SELECT COALESCE(caller, ''), COALESCE(provider, 'unknown'), COUNT(*) "
            f"FROM cost_log {where} "
            f"GROUP BY COALESCE(caller, ''), COALESCE(provider, 'unknown')",
            params,
        )
        providers_per_caller: dict[str, list[str]] = {}
        for row in await cursor.fetchall():
            providers_per_caller.setdefault(row[0] or "", []).append(row[1] or "unknown")

        by_caller = [
            {
                "caller": row[0] or "(ad-hoc)",
                "calls": int(row[1]),
                "input_tokens": int(row[2]),
                "output_tokens": int(row[3]),
                "cost": round(float(row[4]), 6),
                "providers": sorted(set(providers_per_caller.get(row[0] or "", []))),
            }
            for row in caller_rows
        ]

        # By model (for the "which specific models received my data" drill-down)
        cursor = await self._db.execute(
            f"SELECT model, COALESCE(provider, 'unknown'), COALESCE(mode, ''), COUNT(*), "
            f"COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), "
            f"COALESCE(SUM(estimated_cost), 0.0) "
            f"FROM cost_log {where} GROUP BY model ORDER BY COUNT(*) DESC",
            params,
        )
        by_model = [
            {
                "model": row[0],
                "provider": row[1] or "unknown",
                "mode": row[2] or "unknown",
                "calls": int(row[3]),
                "input_tokens": int(row[4]),
                "output_tokens": int(row[5]),
                "cost": round(float(row[6]), 6),
            }
            for row in await cursor.fetchall()
        ]

        return {
            "window": {
                "since": since_iso,
                "until": until_iso,
                "include_local": include_local,
            },
            "totals": totals,
            "by_provider": by_provider,
            "by_caller": by_caller,
            "by_model": by_model,
        }

    async def get_exposure_timeline(
        self,
        since_iso: str | None = None,
        until_iso: str | None = None,
        bucket: str = "day",
        include_local: bool = False,
    ) -> list[dict]:
        """Return exposure bucketed over time for a sparkline/chart.

        bucket: "day" (default) | "hour".
        Rows: [{bucket: iso-prefix, cloud_calls, cloud_input_tokens,
                cloud_output_tokens, cloud_cost, local_calls}, ...] oldest first.
        """
        from datetime import timedelta

        if since_iso is None:
            since_iso = (datetime.now(UTC) - timedelta(days=30)).isoformat()
        if until_iso is None:
            until_iso = datetime.now(UTC).isoformat()
        bucket = bucket if bucket in ("day", "hour") else "day"
        slice_len = 10 if bucket == "day" else 13  # "2026-04-24" / "2026-04-24T12"

        where_mode = "" if include_local else " AND (mode != 'local' OR mode IS NULL)"
        cursor = await self._db.execute(
            f"SELECT SUBSTR(timestamp, 1, {slice_len}) as b, COALESCE(mode, ''), "
            f"COUNT(*), COALESCE(SUM(input_tokens), 0), "
            f"COALESCE(SUM(output_tokens), 0), COALESCE(SUM(estimated_cost), 0.0) "
            f"FROM cost_log WHERE timestamp >= ? AND timestamp <= ?{where_mode} "
            f"GROUP BY b, COALESCE(mode, '') ORDER BY b ASC",
            (since_iso, until_iso),
        )
        raw = await cursor.fetchall()
        buckets: dict[str, dict] = {}
        for row in raw:
            b = row[0]
            m = (row[1] or "").lower()
            entry = buckets.setdefault(b, {
                "bucket": b,
                "cloud_calls": 0, "cloud_input_tokens": 0,
                "cloud_output_tokens": 0, "cloud_cost": 0.0,
                "local_calls": 0,
            })
            if m == "local":
                entry["local_calls"] += int(row[2])
            else:
                entry["cloud_calls"] += int(row[2])
                entry["cloud_input_tokens"] += int(row[3])
                entry["cloud_output_tokens"] += int(row[4])
                entry["cloud_cost"] += float(row[5])
        return [
            {**v, "cloud_cost": round(v["cloud_cost"], 6)}
            for v in sorted(buckets.values(), key=lambda x: x["bucket"])
        ]

    async def get_exposure_recent(
        self,
        limit: int = 50,
        include_local: bool = False,
    ) -> list[dict]:
        """Return the most recent exposure rows, newest first.

        Each row carries: timestamp, provider, mode, model, caller,
        input/output/cached tokens, estimated_cost, correlation_id,
        data_categories (parsed from JSON), action, goal_id.
        """
        limit = max(1, min(int(limit), 500))
        where = ""
        params: list[Any] = []
        if not include_local:
            where = "WHERE (mode != 'local' OR mode IS NULL)"

        cursor = await self._db.execute(
            f"SELECT timestamp, COALESCE(provider, 'unknown'), COALESCE(mode, ''), "
            f"model, COALESCE(caller, ''), input_tokens, output_tokens, "
            f"cache_read_tokens, cache_write_tokens, estimated_cost, "
            f"COALESCE(correlation_id, ''), COALESCE(data_categories, '[]'), "
            f"COALESCE(action, ''), goal_id "
            f"FROM cost_log {where} ORDER BY id DESC LIMIT ?",
            (*params, limit),
        )
        rows = await cursor.fetchall()
        out = []
        for row in rows:
            try:
                cats = json.loads(row[11]) if row[11] else []
                if not isinstance(cats, list):
                    cats = []
            except Exception:
                cats = []
            out.append({
                "timestamp": row[0],
                "provider": row[1] or "unknown",
                "mode": row[2] or "unknown",
                "model": row[3],
                "caller": row[4] or "(ad-hoc)",
                "input_tokens": int(row[5]),
                "output_tokens": int(row[6]),
                "cache_read_tokens": int(row[7] or 0),
                "cache_write_tokens": int(row[8] or 0),
                "estimated_cost": round(float(row[9]), 6),
                "correlation_id": row[10] or "",
                "data_categories": cats,
                "action": row[12] or "",
                "goal_id": row[13],
            })
        return out

    async def get_goal_cost(self, goal_id: str) -> float:
        """Return total cost spent on a specific goal."""
        cursor = await self._db.execute(
            "SELECT COALESCE(SUM(estimated_cost), 0) FROM cost_log WHERE goal_id = ?",
            (goal_id,),
        )
        row = await cursor.fetchone()
        return float(row[0]) if row else 0.0

    async def get_goal_cost_breakdown(self, goal_id: str, limit: int = 100) -> list[dict]:
        """Return individual cost entries for a goal, newest first."""
        cursor = await self._db.execute(
            "SELECT timestamp, model, input_tokens, output_tokens, "
            "cache_read_tokens, cache_write_tokens, estimated_cost, action "
            "FROM cost_log WHERE goal_id = ? ORDER BY timestamp DESC LIMIT ?",
            (goal_id, limit),
        )
        rows = await cursor.fetchall()
        return [
            {
                "timestamp": row[0],
                "model": row[1],
                "input_tokens": row[2],
                "output_tokens": row[3],
                "cache_read_tokens": row[4],
                "cache_write_tokens": row[5],
                "estimated_cost": row[6],
                "action": row[7] or "",
            }
            for row in rows
        ]

    async def check_goal_budget(self, goal_id: str, budget: float) -> dict:
        """Check if a goal's budget allows another dispatch.

        Returns {allowed, spent, budget, remaining}.
        """
        spent = await self.get_goal_cost(goal_id)
        remaining = max(0.0, budget - spent)
        return {
            "allowed": remaining > 0,
            "spent": spent,
            "budget": budget,
            "remaining": remaining,
        }

    async def check_budget(self) -> dict:
        """Check if budget allows another request."""
        daily = await self.get_daily_cost()
        monthly = await self.get_monthly_cost()

        exceeded = False
        if self._daily_budget > 0 and daily >= self._daily_budget:
            exceeded = True
        if self._monthly_budget > 0 and monthly >= self._monthly_budget:
            exceeded = True

        return {
            "allowed": not exceeded,
            "daily_cost": daily,
            "daily_budget": self._daily_budget,
            "monthly_cost": monthly,
            "monthly_budget": self._monthly_budget,
            "daily_remaining": max(0, self._daily_budget - daily),
            "monthly_remaining": max(0, self._monthly_budget - monthly),
            "enforce_budget": self._enforce_budget,
        }

    async def check_budget_or_raise(self) -> None:
        """Pre-flight budget gate.  Raises BudgetExceededError when over budget.

        Call this BEFORE making an LLM request.  When enforce_budget is False
        (user opted out), this is a no-op.
        """
        if not self._enforce_budget:
            return
        result = await self.check_budget()
        if not result["allowed"]:
            raise BudgetExceededError(
                f"Budget exceeded — daily ${result['daily_cost']:.2f}/${result['daily_budget']:.2f}, "
                f"monthly ${result['monthly_cost']:.2f}/${result['monthly_budget']:.2f}. "
                f"Increase your budget in Settings → Costs, or disable enforcement.",
                daily_cost=result["daily_cost"],
                monthly_cost=result["monthly_cost"],
            )

    async def get_daily_cost(self) -> float:
        """Get total cost for today."""
        today = datetime.now(UTC).strftime("%Y-%m-%d")
        cursor = await self._db.execute(
            "SELECT COALESCE(SUM(estimated_cost), 0) FROM cost_log WHERE timestamp >= ?",
            (f"{today}T00:00:00",),
        )
        row = await cursor.fetchone()
        return row[0] if row else 0.0

    async def get_monthly_cost(self) -> float:
        """Get total cost for this month."""
        month_start = datetime.now(UTC).strftime("%Y-%m-01")
        cursor = await self._db.execute(
            "SELECT COALESCE(SUM(estimated_cost), 0) FROM cost_log WHERE timestamp >= ?",
            (f"{month_start}T00:00:00",),
        )
        row = await cursor.fetchone()
        return row[0] if row else 0.0

    async def get_usage_summary(self, days: int = 7) -> dict:
        """Get usage summary for the dashboard."""
        from datetime import timedelta

        cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
        cursor = await self._db.execute(
            "SELECT model, SUM(input_tokens), SUM(output_tokens), "
            "SUM(estimated_cost), COUNT(*), "
            "SUM(cache_read_tokens), SUM(cache_write_tokens) "
            "FROM cost_log "
            "WHERE timestamp >= ? GROUP BY model",
            (cutoff,),
        )
        rows = await cursor.fetchall()

        models = {}
        total_cost = 0
        total_requests = 0
        total_cache_read = 0
        total_cache_write = 0
        for row in rows:
            cr = row[5] or 0
            cw = row[6] or 0
            models[row[0]] = {
                "input_tokens": row[1],
                "output_tokens": row[2],
                "cost": row[3],
                "requests": row[4],
                "cache_read_tokens": cr,
                "cache_write_tokens": cw,
            }
            total_cost += row[3]
            total_requests += row[4]
            total_cache_read += cr
            total_cache_write += cw

        return {
            "period_days": days,
            "total_cost": total_cost,
            "total_requests": total_requests,
            "total_cache_read_tokens": total_cache_read,
            "total_cache_write_tokens": total_cache_write,
            "by_model": models,
            "daily_budget": self._daily_budget,
            "monthly_budget": self._monthly_budget,
            "daily_cost": await self.get_daily_cost(),
            "monthly_cost": await self.get_monthly_cost(),
        }

    def set_budgets(self, daily: float | None = None, monthly: float | None = None) -> None:
        """Update budget limits."""
        if daily is not None:
            self._daily_budget = daily
        if monthly is not None:
            self._monthly_budget = monthly
        log.info(
            "Budgets updated: daily=$%.2f, monthly=$%.2f",
            self._daily_budget, self._monthly_budget,
        )

    async def get_daily_bill(self, date: str | None = None) -> dict:
        """Get itemized daily bill — cost breakdown by action and model.

        Args:
            date: YYYY-MM-DD string. Defaults to today.

        Returns dict with:
        - date, total_cost, total_requests
        - by_action: {action: {model, requests, input_tokens, output_tokens, cost}}
        - by_model: {model: {requests, input_tokens, output_tokens, cost}}
        - items: chronological list of all calls
        """
        if not date:
            date = datetime.now(UTC).strftime("%Y-%m-%d")

        cursor = await self._db.execute(
            "SELECT timestamp, model, input_tokens, output_tokens, estimated_cost, "
            "conversation_id, action, cache_read_tokens, cache_write_tokens "
            "FROM cost_log "
            "WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp",
            (f"{date}T00:00:00", f"{date}T23:59:59"),
        )
        rows = await cursor.fetchall()

        by_action: dict[str, dict] = {}
        by_model: dict[str, dict] = {}
        items = []
        total_cost = 0.0

        for row in rows:
            ts, model, inp, out, cost, conv_id, action, cr, cw = row
            action = action or "unknown"
            cr = cr or 0
            cw = cw or 0
            total_cost += cost

            items.append({
                "time": ts,
                "model": model,
                "input_tokens": inp,
                "output_tokens": out,
                "cache_read_tokens": cr,
                "cache_write_tokens": cw,
                "cost": round(cost, 6),
                "action": action,
                "conversation_id": conv_id,
            })

            # Aggregate by action
            a = by_action.setdefault(action, {
                "requests": 0, "input_tokens": 0, "output_tokens": 0,
                "cache_read_tokens": 0, "cache_write_tokens": 0,
                "cost": 0.0, "models": set(),
            })
            a["requests"] += 1
            a["input_tokens"] += inp
            a["output_tokens"] += out
            a["cache_read_tokens"] += cr
            a["cache_write_tokens"] += cw
            a["cost"] += cost
            a["models"].add(model)

            # Aggregate by model
            m = by_model.setdefault(model, {
                "requests": 0, "input_tokens": 0, "output_tokens": 0,
                "cache_read_tokens": 0, "cache_write_tokens": 0, "cost": 0.0,
            })
            m["requests"] += 1
            m["input_tokens"] += inp
            m["output_tokens"] += out
            m["cache_read_tokens"] += cr
            m["cache_write_tokens"] += cw
            m["cost"] += cost

        # Convert sets to lists for JSON serialization
        for a in by_action.values():
            a["models"] = sorted(a["models"])
            a["cost"] = round(a["cost"], 4)
        for m in by_model.values():
            m["cost"] = round(m["cost"], 4)

        return {
            "date": date,
            "total_cost": round(total_cost, 4),
            "total_requests": len(rows),
            "by_action": by_action,
            "by_model": by_model,
            "items": items,
        }

    async def get_monthly_bill(self, month: str | None = None) -> dict:
        """Get monthly bill — daily totals + model breakdown.

        Args:
            month: YYYY-MM string. Defaults to current month.

        Returns dict with:
        - month, total_cost, total_requests
        - daily_totals: [{date, cost, requests}]
        - by_model: {model: {requests, input_tokens, output_tokens, cost}}
        - by_action: {action: {requests, cost}}
        """
        if not month:
            month = datetime.now(UTC).strftime("%Y-%m")

        month_start = f"{month}-01T00:00:00"
        # Calculate next month for upper bound
        year, mon = int(month[:4]), int(month[5:7])
        if mon == 12:
            next_month = f"{year + 1}-01-01T00:00:00"
        else:
            next_month = f"{year}-{mon + 1:02d}-01T00:00:00"

        # Daily totals
        cursor = await self._db.execute(
            "SELECT SUBSTR(timestamp, 1, 10) as day, "
            "SUM(estimated_cost), COUNT(*) "
            "FROM cost_log WHERE timestamp >= ? AND timestamp < ? "
            "GROUP BY day ORDER BY day",
            (month_start, next_month),
        )
        daily_rows = await cursor.fetchall()

        daily_totals = [
            {"date": row[0], "cost": round(row[1], 4), "requests": row[2]}
            for row in daily_rows
        ]

        # Model breakdown
        cursor = await self._db.execute(
            "SELECT model, SUM(input_tokens), SUM(output_tokens), "
            "SUM(estimated_cost), COUNT(*), "
            "SUM(cache_read_tokens), SUM(cache_write_tokens) "
            "FROM cost_log "
            "WHERE timestamp >= ? AND timestamp < ? GROUP BY model",
            (month_start, next_month),
        )
        model_rows = await cursor.fetchall()

        by_model = {}
        for row in model_rows:
            by_model[row[0]] = {
                "input_tokens": row[1],
                "output_tokens": row[2],
                "cost": round(row[3], 4),
                "requests": row[4],
                "cache_read_tokens": row[5] or 0,
                "cache_write_tokens": row[6] or 0,
            }

        # Action breakdown
        cursor = await self._db.execute(
            "SELECT action, SUM(estimated_cost), COUNT(*) FROM cost_log "
            "WHERE timestamp >= ? AND timestamp < ? GROUP BY action",
            (month_start, next_month),
        )
        action_rows = await cursor.fetchall()

        by_action = {}
        for row in action_rows:
            by_action[row[0] or "unknown"] = {
                "cost": round(row[1], 4),
                "requests": row[2],
            }

        total_cost = sum(d["cost"] for d in daily_totals)

        return {
            "month": month,
            "total_cost": round(total_cost, 4),
            "total_requests": sum(d["requests"] for d in daily_totals),
            "daily_totals": daily_totals,
            "by_model": by_model,
            "by_action": by_action,
            "budget": self._monthly_budget,
            "remaining": round(max(0, self._monthly_budget - total_cost), 4),
        }

    @staticmethod
    def _estimate_cost(
        model: str,
        input_tokens: int,
        output_tokens: int,
        cache_read_tokens: int = 0,
        cache_write_tokens: int = 0,
    ) -> float:
        """Estimate cost using YAML pricing with cache-aware calculation.

        Cost formula:
          standard_input = (input_tokens - cache_read - cache_write) * price_input
          cached_read    = cache_read_tokens * price_cache_read
          cached_write   = cache_write_tokens * price_cache_write
          output         = output_tokens * price_output
          total          = (standard_input + cached_read + cached_write + output) / 1M

        When cache_read/cache_write prices are not in the YAML, falls back to
        the full input price (safe overestimate).
        """
        pricing = get_pricing()
        model_pricing = pricing.get(model)

        # Handle Ollama/local (free) regardless of YAML
        if "ollama" in model:
            return 0.0

        if not model_pricing:
            model_pricing = pricing.get("_default", _FALLBACK_PRICING["_default"])

        price_input = model_pricing.get("input", 3.0)
        price_output = model_pricing.get("output", 15.0)
        # Cache prices: fall back to full input price when not specified
        price_cache_read = model_pricing.get("cache_read", price_input)
        price_cache_write = model_pricing.get("cache_write", price_input)

        # Standard (non-cached) input tokens = total input minus cached portions.
        # Anthropic reports input_tokens as the total INCLUDING cache_read,
        # but cache_write is separate (added on top).  OpenAI's prompt_tokens
        # includes cached_tokens.  So: standard = input - cache_read.
        standard_input = max(0, input_tokens - cache_read_tokens)

        cost = (
            standard_input * price_input
            + cache_read_tokens * price_cache_read
            + cache_write_tokens * price_cache_write
            + output_tokens * price_output
        ) / 1_000_000

        return cost
