"""CacheManager — SQLite-backed input-hash cache for pipeline steps.

Per-step-per-row caching with SHA-256 keys. Uses WAL mode for
concurrent access. Single `.accrue/cache.db` file, zero new deps.
"""

from __future__ import annotations

import hashlib
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

# How often to re-run TTL cleanup on init (seconds).
_CLEANUP_INTERVAL = 86400  # 1 day


class CacheManager:
    """SQLite-backed cache for step results.

    Each entry is keyed by a SHA-256 hash of the step's input
    (row data, prior results, field specs, model, temperature, etc.).
    TTL expiry is lazy — checked on ``get()``, bulk-cleaned via
    ``cleanup_expired()``.

    Args:
        cache_dir: Directory for ``cache.db``. Created if absent.
        ttl: Time-to-live in seconds. 0 = no expiry.
    """

    def __init__(self, cache_dir: str = ".accrue", ttl: int = 3600) -> None:
        self._cache_dir = Path(cache_dir)
        self._ttl = ttl
        self._conn: sqlite3.Connection | None = None
        # RLock so write-protected methods can call _ensure_connection (which
        # itself acquires the lock for the one-time setup) without deadlocking.
        self._write_lock = threading.RLock()

    def _ensure_connection(self) -> sqlite3.Connection:
        if self._conn is not None:
            return self._conn

        with self._write_lock:
            # Double-checked locking: another thread may have completed setup
            # while we were waiting on the lock.
            if self._conn is not None:
                return self._conn

            self._cache_dir.mkdir(parents=True, exist_ok=True)
            db_path = self._cache_dir / "cache.db"

            conn = sqlite3.connect(str(db_path), check_same_thread=False)
            if os.name == "posix":
                try:
                    os.chmod(db_path, 0o600)
                except (OSError, NotImplementedError):
                    pass
            conn.execute("PRAGMA journal_mode=WAL")
            conn.execute("PRAGMA busy_timeout=5000")
            conn.execute("PRAGMA synchronous=NORMAL")
            conn.execute("PRAGMA cache_size=-8000")  # 8 MB

            conn.execute("""
                CREATE TABLE IF NOT EXISTS cache (
                    key        TEXT PRIMARY KEY,
                    step_name  TEXT NOT NULL,
                    value      TEXT NOT NULL,
                    created_at REAL NOT NULL,
                    expires_at REAL
                )
            """)
            conn.execute("CREATE INDEX IF NOT EXISTS idx_step ON cache(step_name)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_expires ON cache(expires_at)")

            # Meta table for tracking lazy cleanup
            conn.execute("""
                CREATE TABLE IF NOT EXISTS cache_meta (
                    key   TEXT PRIMARY KEY,
                    value TEXT NOT NULL
                )
            """)
            conn.commit()

            # Publish the fully-initialized connection only after DDL + commit
            # so other threads never observe a half-constructed schema.
            self._conn = conn

            # Lazy TTL cleanup: run once per process, gated by a meta marker.
            # Safe to call here because _write_lock is reentrant.
            self._maybe_cleanup_on_init()

            return self._conn

    def _maybe_cleanup_on_init(self) -> None:
        """Run cleanup_expired() once per process if it hasn't run recently."""
        assert self._conn is not None
        row = self._conn.execute(
            "SELECT value FROM cache_meta WHERE key = 'last_cleanup'"
        ).fetchone()

        should_clean = True
        if row is not None:
            try:
                last_ts = datetime.fromisoformat(row[0])
                age = (datetime.now(timezone.utc) - last_ts).total_seconds()
                if age < _CLEANUP_INTERVAL:
                    should_clean = False
            except ValueError:
                pass  # Malformed timestamp — clean anyway

        if should_clean:
            self.cleanup_expired()
            now_iso = datetime.now(timezone.utc).isoformat()
            with self._write_lock:
                self._conn.execute(
                    "INSERT OR REPLACE INTO cache_meta (key, value) VALUES ('last_cleanup', ?)",
                    (now_iso,),
                )
                self._conn.commit()

    def get(self, key: str) -> dict | None:
        """Look up a cached value by key. Returns None on miss or expiry."""
        conn = self._ensure_connection()
        row = conn.execute("SELECT value, expires_at FROM cache WHERE key = ?", (key,)).fetchone()

        if row is None:
            return None

        value_json, expires_at = row
        if expires_at is not None and time.time() > expires_at:
            with self._write_lock:
                conn.execute("DELETE FROM cache WHERE key = ?", (key,))
                conn.commit()
            return None

        return json.loads(value_json)

    def set(self, key: str, step_name: str, value: dict) -> None:
        """Store a value in the cache."""
        conn = self._ensure_connection()
        now = time.time()
        expires_at = (now + self._ttl) if self._ttl > 0 else None

        with self._write_lock:
            conn.execute(
                "INSERT OR REPLACE INTO cache (key, step_name, value, created_at, expires_at) "
                "VALUES (?, ?, ?, ?, ?)",
                (key, step_name, json.dumps(value, default=str), now, expires_at),
            )
            conn.commit()

    def delete_step(self, step_name: str) -> int:
        """Delete all cache entries for a step. Returns count deleted."""
        conn = self._ensure_connection()
        with self._write_lock:
            cursor = conn.execute("DELETE FROM cache WHERE step_name = ?", (step_name,))
            conn.commit()
        return cursor.rowcount

    def delete_all(self) -> int:
        """Delete all cache entries. Returns count deleted."""
        conn = self._ensure_connection()
        with self._write_lock:
            cursor = conn.execute("DELETE FROM cache")
            conn.commit()
        return cursor.rowcount

    def cleanup_expired(self) -> int:
        """Remove all expired entries. Returns count deleted."""
        conn = self._ensure_connection()
        with self._write_lock:
            cursor = conn.execute(
                "DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?",
                (time.time(),),
            )
            conn.commit()
        return cursor.rowcount

    def close(self) -> None:
        """Close the database connection."""
        if self._conn is not None:
            self._conn.close()
            self._conn = None


# ---------------------------------------------------------------------------
# Cache key computation
# ---------------------------------------------------------------------------


def canonical_json(obj: Any) -> str:
    """Deterministic JSON: sorted keys, compact separators, str fallback."""
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)


def compute_cache_key(**components: Any) -> str:
    """SHA-256 hex digest of canonical JSON of *components*."""
    payload = canonical_json(components)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def _step_schema_hash(step: Any) -> str:
    """Return a memoised SHA-256 hash of the LLMStep's response JSON schema.

    The hash is cached on ``step._cached_schema_hash`` so it is computed at
    most once per step instance regardless of how many rows are processed.
    If the step has no ``_field_specs`` (e.g. list-fields form), returns an
    empty string so the cache key remains stable for those steps.
    """
    cached = getattr(step, "_cached_schema_hash", None)
    if cached is not None:
        return cached

    field_specs = getattr(step, "_field_specs", None)
    if not field_specs:
        step._cached_schema_hash = ""
        return ""

    # Late import to avoid circular dependency: cache.py ← steps/schema_builder.py
    from accrue.steps.schema_builder import build_json_schema  # noqa: PLC0415

    schema_dict = build_json_schema(field_specs)
    digest = hashlib.sha256(
        json.dumps(schema_dict, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    step._cached_schema_hash = digest
    return digest


def _compute_step_cache_key(
    step: Any,
    row: dict[str, Any],
    prior_results: dict[str, Any],
    step_fields: dict[str, Any],
) -> str:
    """Build the cache key for a step + row, duck-typing the step.

    LLMStep has ``model`` and ``temperature``; FunctionStep has ``cache_version``.
    """
    if getattr(step, "model", None) is not None:
        # LLMStep path
        system_prompt = getattr(step, "_custom_system_prompt", None) or ""
        system_prompt_header = getattr(step, "_system_prompt_header", None) or ""
        # Include grounding config in cache key when present
        grounding_cfg = getattr(step, "_grounding_config", None)
        grounding_hash = ""
        if grounding_cfg is not None:
            grounding_hash = hashlib.sha256(
                grounding_cfg.model_dump_json().encode("utf-8")
            ).hexdigest()
        # provider_kwargs: canonical JSON hash (empty dict and None are equivalent)
        pk = getattr(step, "provider_kwargs", None) or {}
        provider_kwargs_hash = hashlib.sha256(
            json.dumps(pk, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
        ).hexdigest()
        return compute_cache_key(
            step_name=step.name,
            row=row,
            prior_results=prior_results,
            field_specs=step_fields,
            model=step.model,
            temperature=getattr(step, "temperature", None),
            max_tokens=getattr(step, "max_tokens", None),
            system_prompt_hash=hashlib.sha256(system_prompt.encode("utf-8")).hexdigest(),
            system_prompt_header_hash=hashlib.sha256(
                system_prompt_header.encode("utf-8")
            ).hexdigest(),
            grounding_hash=grounding_hash,
            schema_hash=_step_schema_hash(step),
            provider_kwargs_hash=provider_kwargs_hash,
        )
    else:
        # FunctionStep path
        return compute_cache_key(
            step_name=step.name,
            row=row,
            prior_results=prior_results,
            cache_version=getattr(step, "cache_version", None),
        )
