// Package cache implements a CLI-side SQLite-backed key/value cache with
// per-class TTLs (R-008). It is the on-disk cache layer used by read-only
// `orca` subcommands (node/job/ns list) to avoid hitting the source DB
// or filesystem on every invocation.
//
// The cache is intentionally optional: callers that fail to open the
// cache DB must fall back to the uncached read path silently. Writes
// bypass the cache entirely (cache invalidation is per-class or
// whole-DB only — there is no write-through path).
//
// Schema (orca_cache):
//
//	CREATE TABLE cache_entries (
//	  class        TEXT,
//	  key          TEXT,
//	  value        BLOB,
//	  inserted_at  INTEGER,  -- unix nanoseconds
//	  ttl_seconds  INTEGER,  -- TTL in nanoseconds; 0 = never expires
//	  PRIMARY KEY (class, key)
//	);
//
// The schema columns match the v0.11 plan (R-008); the integer columns
// are stored at nanosecond resolution so sub-second TTLs (used in tests
// and short-lived caches like the 10s job-list cache) work correctly.
package cache

import (
	"database/sql"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	_ "modernc.org/sqlite"

	"git.cloudinit.dev/coreci/orca/internal/paths"
)

// ErrCacheMiss is returned (wrapped) by Get when an entry is absent or
// expired. Callers that want a silent miss should treat any error
// satisfying errors.Is(err, ErrCacheMiss) as "not in cache".
var ErrCacheMiss = errors.New("cache miss")

// Cache wraps a SQLite-backed key/value cache with per-class TTLs.
type Cache struct {
	db *sql.DB
}

// Open opens (or creates) the SQLite cache DB at path. If path is empty
// it defaults to paths.CacheDB(). The DB is created with WAL journal
// mode (matching internal/store). The schema is idempotent
// (CREATE TABLE IF NOT EXISTS).
func Open(path string) (*Cache, error) {
	if path == "" {
		path = paths.CacheDB()
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return nil, fmt.Errorf("create cache db dir: %w", err)
	}
	db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)")
	if err != nil {
		return nil, fmt.Errorf("open cache sqlite: %w", err)
	}
	if err := db.Ping(); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("ping cache sqlite: %w", err)
	}
	const schema = `CREATE TABLE IF NOT EXISTS cache_entries (
		class       TEXT NOT NULL,
		key         TEXT NOT NULL,
		value       BLOB NOT NULL,
		inserted_at INTEGER NOT NULL,
		ttl_seconds INTEGER NOT NULL,
		PRIMARY KEY (class, key)
	)`
	if _, err := db.Exec(schema); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("create cache schema: %w", err)
	}
	return &Cache{db: db}, nil
}

// Get returns the cached value and insertion time for (class, key).
// On a miss or expired entry Get returns (nil, zero, ErrCacheMiss).
func (c *Cache) Get(class, key string) ([]byte, time.Time, error) {
	const q = `SELECT value, inserted_at, ttl_seconds FROM cache_entries WHERE class = ? AND key = ?`
	var (
		val      []byte
		inserted int64
		ttlNanos int64
	)
	err := c.db.QueryRow(q, class, key).Scan(&val, &inserted, &ttlNanos)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, time.Time{}, ErrCacheMiss
		}
		return nil, time.Time{}, fmt.Errorf("cache get %s/%s: %w", class, key, err)
	}
	if ttlNanos > 0 {
		expiresAt := time.Unix(0, inserted).Add(time.Duration(ttlNanos))
		if time.Now().After(expiresAt) {
			_, _ = c.db.Exec(`DELETE FROM cache_entries WHERE class = ? AND key = ?`, class, key)
			return nil, time.Time{}, ErrCacheMiss
		}
	}
	return val, time.Unix(0, inserted).UTC(), nil
}

// Set stores val for (class, key) with the given ttl. A ttl of 0 means
// the entry never expires. An existing entry for (class, key) is
// replaced (UPSERT).
func (c *Cache) Set(class, key string, val []byte, ttl time.Duration) error {
	inserted := time.Now().UTC().UnixNano()
	ttlNanos := int64(ttl)
	const q = `INSERT INTO cache_entries (class, key, value, inserted_at, ttl_seconds)
		VALUES (?, ?, ?, ?, ?)
		ON CONFLICT(class, key) DO UPDATE SET
			value = excluded.value,
			inserted_at = excluded.inserted_at,
			ttl_seconds = excluded.ttl_seconds`
	if _, err := c.db.Exec(q, class, key, val, inserted, ttlNanos); err != nil {
		return fmt.Errorf("cache set %s/%s: %w", class, key, err)
	}
	return nil
}

// Invalidate removes all entries for class.
func (c *Cache) Invalidate(class string) error {
	if _, err := c.db.Exec(`DELETE FROM cache_entries WHERE class = ?`, class); err != nil {
		return fmt.Errorf("cache invalidate %s: %w", class, err)
	}
	return nil
}

// InvalidateKey removes a single (class, key) entry.
func (c *Cache) InvalidateKey(class, key string) error {
	if _, err := c.db.Exec(`DELETE FROM cache_entries WHERE class = ? AND key = ?`, class, key); err != nil {
		return fmt.Errorf("cache invalidate %s/%s: %w", class, key, err)
	}
	return nil
}

// Close releases the underlying DB handle.
func (c *Cache) Close() error {
	if c == nil || c.db == nil {
		return nil
	}
	return c.db.Close()
}

// ClassStats describes one cache class for `orca cache show`.
type ClassStats struct {
	Class    string `json:"class"`
	Count    int    `json:"count"`
	Bytes    int64  `json:"bytes"`
	OldestAt int64  `json:"oldest_at"`
}

// Stats returns per-class entry counts, total bytes, and oldest
// insertion time. Used by `orca cache show`.
func (c *Cache) Stats() ([]ClassStats, error) {
	const q = `SELECT class,
		COUNT(*) AS count,
		COALESCE(SUM(LENGTH(value)), 0) AS bytes,
		COALESCE(MIN(inserted_at), 0) AS oldest
		FROM cache_entries GROUP BY class ORDER BY class`
	rows, err := c.db.Query(q)
	if err != nil {
		return nil, fmt.Errorf("cache stats: %w", err)
	}
	defer rows.Close()
	var out []ClassStats
	for rows.Next() {
		var s ClassStats
		if err := rows.Scan(&s.Class, &s.Count, &s.Bytes, &s.OldestAt); err != nil {
			return nil, fmt.Errorf("cache stats scan: %w", err)
		}
		out = append(out, s)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("cache stats rows: %w", err)
	}
	return out, nil
}

// InvalidateAll clears every entry in the cache.
func (c *Cache) InvalidateAll() error {
	if _, err := c.db.Exec(`DELETE FROM cache_entries`); err != nil {
		return fmt.Errorf("cache invalidate-all: %w", err)
	}
	return nil
}

// Classes returns the distinct class names in the cache.
func (c *Cache) Classes() ([]string, error) {
	rows, err := c.db.Query(`SELECT DISTINCT class FROM cache_entries ORDER BY class`)
	if err != nil {
		return nil, fmt.Errorf("cache classes: %w", err)
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var name string
		if err := rows.Scan(&name); err != nil {
			return nil, fmt.Errorf("cache classes scan: %w", err)
		}
		out = append(out, name)
	}
	return out, rows.Err()
}
