"""Middleware cache layer for Claude Code hooks.

Provides caching for repetitive tool calls (Read, Grep, Glob, WebFetch).
Cache is per-terminal-session and stored in the cortex.db SQLite database.

All functions are fail-open: errors return safe defaults and never raise.
This is critical — the Feb 14 incident showed that aggressive hooks can
cascade into session-breaking failures.

Usage in hooks:
    from middleware_cache import (
        compute_cache_key, check_cache, store_cache,
        invalidate_file_cache, clear_session_cache,
        cleanup_expired_cache, ensure_cache_table,
    )
"""

import hashlib
import json
import os
import re
import sqlite3
import sys
import time as _time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional


# === Cache Table DDL ===

CACHE_TABLE_DDL = """
CREATE TABLE IF NOT EXISTS middleware_cache (
    cache_key TEXT NOT NULL,
    terminal_session_id TEXT NOT NULL,
    tool_name TEXT NOT NULL,
    tool_input_json TEXT,
    cached_response TEXT NOT NULL,
    created_at TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    hit_count INTEGER DEFAULT 0,
    response_size INTEGER DEFAULT 0,
    PRIMARY KEY (cache_key, terminal_session_id)
);
CREATE INDEX IF NOT EXISTS idx_cache_session ON middleware_cache(terminal_session_id);
CREATE INDEX IF NOT EXISTS idx_cache_expires ON middleware_cache(expires_at);
"""


def ensure_cache_table(conn: sqlite3.Connection) -> None:
    """Create the middleware_cache table if it doesn't exist.

    Safe to call multiple times (uses IF NOT EXISTS).
    """
    try:
        conn.executescript(CACHE_TABLE_DDL)
    except Exception:
        pass  # Fail-open: table creation is best-effort


# === Path Normalization ===

def normalize_path(path: str) -> str:
    """Normalize a file path for consistent cache keys.

    - Replaces backslashes with forward slashes
    - Lowercases drive letter on Windows (D: -> d:)
    - Strips trailing slashes

    This ensures D:\\Projects\\foo and D:/Projects/foo produce the same cache key.
    """
    if not path:
        return path
    # Replace backslashes
    normalized = path.replace("\\", "/")
    # Lowercase Windows drive letter (D:/... -> d:/...)
    if len(normalized) >= 2 and normalized[1] == ":":
        normalized = normalized[0].lower() + normalized[1:]
    # Strip trailing slash (but not root /)
    if len(normalized) > 1:
        normalized = normalized.rstrip("/")
    return normalized


# === Cache Key Computation ===

def _canonical_json(obj: dict) -> str:
    """Produce deterministic JSON for cache key computation.

    Sorts keys, strips whitespace, normalizes file paths.
    """
    if not isinstance(obj, dict):
        return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)

    normalized = {}
    for key, value in sorted(obj.items()):
        # Normalize path-like fields
        if key in ("file_path", "path") and isinstance(value, str):
            normalized[key] = normalize_path(value)
        elif isinstance(value, dict):
            normalized[key] = json.loads(_canonical_json(value))
        elif isinstance(value, list):
            normalized[key] = value
        else:
            normalized[key] = value

    return json.dumps(normalized, sort_keys=True, separators=(",", ":"), default=str)


def compute_cache_key(tool_name: str, tool_input: dict) -> str:
    """Compute deterministic SHA-256 cache key from tool call.

    Args:
        tool_name: Name of the tool (Read, Grep, Glob, WebFetch)
        tool_input: Tool input dictionary

    Returns:
        Hex SHA-256 hash string
    """
    try:
        canonical = tool_name + "|" + _canonical_json(tool_input)
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    except Exception:
        # On any error, return a unique key that won't match anything
        return hashlib.sha256(os.urandom(32)).hexdigest()


# === SQLite Retry Helper ===

_RETRY_BACKOFF = [0.05, 0.1, 0.2, 0.5, 1.0]


def _retry_execute(conn, sql, params=(), max_attempts=5):
    """Execute SQL with retry-on-lock backoff."""
    cursor = conn.cursor()
    for attempt in range(max_attempts):
        try:
            cursor.execute(sql, params)
            conn.commit()
            return cursor
        except sqlite3.OperationalError as e:
            if "database is locked" in str(e) and attempt < max_attempts - 1:
                _time.sleep(_RETRY_BACKOFF[min(attempt, len(_RETRY_BACKOFF) - 1)])
                continue
            raise
    return cursor


# === Cache Operations ===

def check_cache(
    db_path: Path,
    cache_key: str,
    terminal_session_id: str,
) -> Optional[str]:
    """Check cache for a hit.

    Args:
        db_path: Path to cortex.db
        cache_key: SHA-256 hash from compute_cache_key
        terminal_session_id: Current terminal's session ID

    Returns:
        Cached response string on hit, None on miss or error.
    """
    try:
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA journal_mode = WAL")
        conn.execute("PRAGMA busy_timeout = 5000")  # Shorter timeout for cache reads

        cursor = conn.cursor()
        cursor.execute(
            """
            SELECT cached_response, hit_count
            FROM middleware_cache
            WHERE cache_key = ?
              AND terminal_session_id = ?
              AND expires_at > ?
            """,
            (cache_key, terminal_session_id, datetime.now(timezone.utc).isoformat()),
        )
        row = cursor.fetchone()

        if row is None:
            conn.close()
            return None

        cached_response, hit_count = row

        # Increment hit count (best-effort, don't fail on this)
        try:
            _retry_execute(
                conn,
                """
                UPDATE middleware_cache
                SET hit_count = ?
                WHERE cache_key = ? AND terminal_session_id = ?
                """,
                (hit_count + 1, cache_key, terminal_session_id),
            )
        except Exception:
            pass

        conn.close()
        return cached_response

    except Exception:
        return None  # Fail-open: cache miss


def store_cache(
    db_path: Path,
    cache_key: str,
    terminal_session_id: str,
    tool_name: str,
    tool_input: dict,
    response: str,
    ttl_seconds: int,
    max_size: int,
) -> bool:
    """Store a tool response in the cache.

    Args:
        db_path: Path to cortex.db
        cache_key: SHA-256 hash from compute_cache_key
        terminal_session_id: Current terminal's session ID
        tool_name: Name of the tool
        tool_input: Original tool input (for debugging)
        response: Tool response to cache
        ttl_seconds: Time-to-live in seconds
        max_size: Maximum cached response size in chars

    Returns:
        True on success, False on failure.
    """
    try:
        response_size = len(response)
        # Truncate if over max size
        if response_size > max_size:
            response = response[:max_size] + f"\n[...truncated from {response_size} chars]"

        now = datetime.now(timezone.utc)
        created_at = now.isoformat()
        expires_at = datetime.fromtimestamp(
            now.timestamp() + ttl_seconds, tz=timezone.utc
        ).isoformat()

        tool_input_json = json.dumps(tool_input, default=str)[:2000]

        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA journal_mode = WAL")
        conn.execute("PRAGMA busy_timeout = 5000")

        _retry_execute(
            conn,
            """
            INSERT OR REPLACE INTO middleware_cache (
                cache_key, terminal_session_id, tool_name,
                tool_input_json, cached_response, created_at,
                expires_at, hit_count, response_size
            ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
            """,
            (
                cache_key, terminal_session_id, tool_name,
                tool_input_json, response, created_at,
                expires_at, response_size,
            ),
        )

        conn.close()
        return True

    except Exception:
        return False  # Fail-open


def invalidate_file_cache(
    db_path: Path,
    terminal_session_id: str,
    file_path: str,
) -> int:
    """Invalidate all Read cache entries for a file path.

    Called when Edit or Write modifies a file — the cached Read for that
    file is now stale and must be purged.

    Args:
        db_path: Path to cortex.db
        terminal_session_id: Current terminal's session ID
        file_path: The file that was modified

    Returns:
        Number of cache entries removed. 0 on error.
    """
    try:
        normalized = normalize_path(file_path)

        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA journal_mode = WAL")
        conn.execute("PRAGMA busy_timeout = 5000")

        cursor = conn.cursor()

        # Delete Read cache entries that match the file path
        # We check tool_input_json for the normalized path since the cache_key
        # is a hash and we can't reverse it
        cursor.execute(
            """
            DELETE FROM middleware_cache
            WHERE terminal_session_id = ?
              AND tool_name = 'Read'
              AND (
                  tool_input_json LIKE ?
                  OR tool_input_json LIKE ?
              )
            """,
            (
                terminal_session_id,
                f'%{normalized}%',
                f'%{file_path}%',
            ),
        )
        conn.commit()
        removed = cursor.rowcount

        # Also invalidate Grep/Glob entries that might reference this file's directory
        # This is conservative — better to miss cache than serve stale results
        parent_dir = normalize_path(str(Path(file_path).parent))
        if parent_dir:
            cursor.execute(
                """
                DELETE FROM middleware_cache
                WHERE terminal_session_id = ?
                  AND tool_name IN ('Grep', 'Glob')
                  AND (
                      tool_input_json LIKE ?
                      OR tool_input_json LIKE ?
                  )
                """,
                (
                    terminal_session_id,
                    f'%{parent_dir}%',
                    f'%{str(Path(file_path).parent)}%',
                ),
            )
            conn.commit()
            removed += cursor.rowcount

        conn.close()
        return removed

    except Exception:
        return 0  # Fail-open


def clear_session_cache(
    db_path: Path,
    terminal_session_id: str,
) -> int:
    """Clear all cache entries for a terminal session.

    Used by /handoff and /clear-cache commands.

    Args:
        db_path: Path to cortex.db
        terminal_session_id: Session to clear

    Returns:
        Number of entries removed. 0 on error.
    """
    try:
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA journal_mode = WAL")
        conn.execute("PRAGMA busy_timeout = 5000")

        cursor = conn.cursor()
        cursor.execute(
            "DELETE FROM middleware_cache WHERE terminal_session_id = ?",
            (terminal_session_id,),
        )
        conn.commit()
        removed = cursor.rowcount
        conn.close()
        return removed

    except Exception:
        return 0  # Fail-open


def cleanup_expired_cache(db_path: Path) -> int:
    """Remove all expired cache entries across all sessions.

    Should be called periodically (e.g., every ~50th PostToolUse invocation)
    to prevent cache table bloat.

    Args:
        db_path: Path to cortex.db

    Returns:
        Number of entries removed. 0 on error.
    """
    try:
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA journal_mode = WAL")
        conn.execute("PRAGMA busy_timeout = 5000")

        cursor = conn.cursor()
        cursor.execute(
            "DELETE FROM middleware_cache WHERE expires_at < ?",
            (datetime.now(timezone.utc).isoformat(),),
        )
        conn.commit()
        removed = cursor.rowcount
        conn.close()
        return removed

    except Exception:
        return 0  # Fail-open


# === systemMessage Formatting ===

def format_cache_hit_message(
    tool_name: str,
    tool_input: dict,
    cached_response: str,
    hit_count: int,
    created_at_iso: str,
    verbose: bool = False,
) -> str:
    """Format a systemMessage for a cache hit.

    Args:
        tool_name: The cached tool name
        tool_input: Original tool input
        cached_response: The cached content
        hit_count: Number of times this entry has been hit
        created_at_iso: When the cache entry was created
        verbose: Whether to include detailed cache metadata

    Returns:
        Formatted systemMessage string
    """
    try:
        # Build a brief description of what was cached
        brief = _tool_brief(tool_name, tool_input)

        # Calculate age
        age_str = _format_age(created_at_iso)
        size_str = _format_size(len(cached_response))

        if verbose:
            header = f"[Cache Hit] {tool_name}: {brief} ({age_str} old, {size_str}, hit #{hit_count})"
        else:
            header = f"[Cached] {tool_name}: {brief}"

        return f"{header}\n---\n{cached_response}\n---\n(cached {age_str} ago, {size_str})"

    except Exception:
        # Fallback: just return the cached response
        return cached_response


def format_cache_miss_message(
    tool_name: str,
    tool_input: dict,
    ttl_seconds: int,
) -> str:
    """Format a systemMessage for a cache miss (verbose mode only).

    Args:
        tool_name: The tool being called
        tool_input: Tool input
        ttl_seconds: TTL that will be applied

    Returns:
        Formatted systemMessage string
    """
    brief = _tool_brief(tool_name, tool_input)
    return f"[Cache Miss] {tool_name}: {brief} -- will cache result (TTL: {ttl_seconds}s)"


def _tool_brief(tool_name: str, tool_input: dict) -> str:
    """Generate a brief description of a tool call."""
    if not isinstance(tool_input, dict):
        return ""

    if tool_name == "Read":
        path = tool_input.get("file_path", "")
        return Path(path).name if path else "file"
    elif tool_name == "Grep":
        pattern = tool_input.get("pattern", "")
        path = tool_input.get("path", "")
        return f'"{pattern}" in {Path(path).name if path else "cwd"}'
    elif tool_name == "Glob":
        pattern = tool_input.get("pattern", "")
        return f'"{pattern}"'
    elif tool_name == "WebFetch":
        url = tool_input.get("url", "")
        # Show domain only
        try:
            from urllib.parse import urlparse
            domain = urlparse(url).netloc
            return domain or url[:50]
        except Exception:
            return url[:50]
    return tool_name


def _format_age(created_at_iso: str) -> str:
    """Format the age of a cache entry as human-readable string."""
    try:
        created = datetime.fromisoformat(created_at_iso.replace("Z", "+00:00"))
        now = datetime.now(timezone.utc)
        seconds = (now - created).total_seconds()
        if seconds < 60:
            return f"{int(seconds)}s"
        elif seconds < 3600:
            return f"{int(seconds / 60)}m"
        else:
            return f"{int(seconds / 3600)}h"
    except Exception:
        return "?"


def _format_size(chars: int) -> str:
    """Format size in chars as human-readable string."""
    if chars < 1000:
        return f"{chars} chars"
    elif chars < 1000000:
        return f"{chars / 1000:.1f}KB"
    else:
        return f"{chars / 1000000:.1f}MB"
