//! SQLite database for observation storage.

use std::path::Path;

use chrono::{Duration, Utc};
use rusqlite::{params, Connection, Result as SqliteResult};
use tracing::{debug, instrument, warn};

use super::types::Observation;

/// SQLite database for storing LLM extraction observations.
///
/// The database uses a simple schema optimized for:
/// - Fast inserts during scans
/// - Efficient queries by timestamp and prompt hash
/// - Automatic retention enforcement
///
/// # Thread Safety
///
/// This type is `Send` but not `Sync` because `rusqlite::Connection`
/// is not thread-safe. For concurrent access from multiple threads,
/// either:
/// - Create a separate `EvalDatabase` instance per thread
/// - Use a connection pool like `r2d2_sqlite`
/// - Wrap in `Mutex<EvalDatabase>` for shared access
pub struct EvalDatabase {
    conn: Connection,
}

impl EvalDatabase {
    /// Open or create the evaluation database at the given path.
    ///
    /// Creates the parent directory if it doesn't exist.
    /// Initializes the schema if the database is new.
    #[instrument(skip_all, fields(path = %path.as_ref().display()))]
    pub fn open<P: AsRef<Path>>(path: P) -> SqliteResult<Self> {
        let path = path.as_ref();

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            if let Err(e) = std::fs::create_dir_all(parent) {
                warn!(error = %e, "Failed to create database directory");
            }
        }

        let conn = Connection::open(path)?;

        // Initialize schema
        conn.execute_batch(
            r#"
            CREATE TABLE IF NOT EXISTS observations (
                id TEXT PRIMARY KEY,
                timestamp TEXT NOT NULL,
                prompt_version TEXT NOT NULL,
                prompt_hash TEXT NOT NULL,
                model TEXT NOT NULL,
                input_hash TEXT NOT NULL,
                file_path TEXT NOT NULL,
                language TEXT NOT NULL,
                content_length INTEGER NOT NULL,
                raw_response TEXT NOT NULL,
                parsed_claims TEXT NOT NULL,
                final_claims TEXT NOT NULL,
                input_tokens INTEGER NOT NULL,
                output_tokens INTEGER NOT NULL,
                parse_success INTEGER NOT NULL,
                parse_error TEXT,
                cache_hit INTEGER NOT NULL,
                latency_ms INTEGER NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_obs_timestamp ON observations(timestamp);
            CREATE INDEX IF NOT EXISTS idx_obs_prompt_hash ON observations(prompt_hash);
            CREATE INDEX IF NOT EXISTS idx_obs_model ON observations(model);
            CREATE INDEX IF NOT EXISTS idx_obs_file_path ON observations(file_path);
            "#,
        )?;

        debug!("Database initialized");
        Ok(Self { conn })
    }

    /// Insert a new observation into the database.
    #[instrument(skip(self, obs), fields(obs_id = %obs.id, file = %obs.file_path))]
    pub fn insert(&self, obs: &Observation) -> SqliteResult<()> {
        let parsed_claims_json = serde_json::to_string(&obs.parsed_claims)
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        let final_claims_json = serde_json::to_string(&obs.final_claims)
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;

        self.conn.execute(
            r#"
            INSERT INTO observations (
                id, timestamp, prompt_version, prompt_hash, model, input_hash,
                file_path, language, content_length, raw_response, parsed_claims,
                final_claims, input_tokens, output_tokens, parse_success,
                parse_error, cache_hit, latency_ms
            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
            "#,
            params![
                obs.id.to_string(),
                obs.timestamp.to_rfc3339(),
                obs.prompt_version,
                obs.prompt_hash,
                obs.model,
                obs.input_hash,
                obs.file_path,
                obs.language,
                obs.content_length,
                obs.raw_response,
                parsed_claims_json,
                final_claims_json,
                obs.input_tokens,
                obs.output_tokens,
                obs.parse_success,
                obs.parse_error,
                obs.cache_hit,
                obs.latency_ms,
            ],
        )?;

        debug!("Observation inserted");
        Ok(())
    }

    /// Enforce retention policy: keep observations from last N days OR last M count.
    ///
    /// Deletes observations older than `retention_days` that are also beyond
    /// the `max_count` most recent observations. This ensures we always keep
    /// at least `max_count` observations regardless of age.
    #[instrument(skip(self), fields(retention_days, max_count))]
    pub fn enforce_retention(&self, retention_days: i64, max_count: usize) -> SqliteResult<usize> {
        let cutoff = Utc::now() - Duration::days(retention_days);

        // Delete observations older than cutoff, but keep at least max_count
        let deleted = self.conn.execute(
            r#"
            DELETE FROM observations
            WHERE timestamp < ?1
            AND id NOT IN (
                SELECT id FROM observations
                ORDER BY timestamp DESC
                LIMIT ?2
            )
            "#,
            params![cutoff.to_rfc3339(), max_count],
        )?;

        if deleted > 0 {
            debug!(deleted, "Retention enforced, observations deleted");
        }

        Ok(deleted)
    }

    /// Get the total number of observations in the database.
    pub fn count(&self) -> SqliteResult<usize> {
        self.conn.query_row("SELECT COUNT(*) FROM observations", [], |row| row.get(0))
    }

    /// Get observations by prompt hash for A/B comparison.
    #[instrument(skip(self))]
    pub fn get_by_prompt_hash(
        &self,
        prompt_hash: &str,
        limit: usize,
    ) -> SqliteResult<Vec<Observation>> {
        let mut stmt = self.conn.prepare(
            r#"
            SELECT id, timestamp, prompt_version, prompt_hash, model, input_hash,
                   file_path, language, content_length, raw_response, parsed_claims,
                   final_claims, input_tokens, output_tokens, parse_success,
                   parse_error, cache_hit, latency_ms
            FROM observations
            WHERE prompt_hash = ?1
            ORDER BY timestamp DESC
            LIMIT ?2
            "#,
        )?;

        let rows = stmt.query_map(params![prompt_hash, limit], Self::row_to_observation)?;

        let observations: Vec<Observation> = rows
            .filter_map(|row| match row {
                Ok(obs) => Some(obs),
                Err(e) => {
                    warn!(error = %e, "Failed to parse observation row, skipping");
                    None
                }
            })
            .collect();

        Ok(observations)
    }

    /// Convert a database row to an Observation.
    fn row_to_observation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Observation> {
        let id_str: String = row.get(0)?;
        let timestamp_str: String = row.get(1)?;
        let parsed_claims_json: String = row.get(10)?;
        let final_claims_json: String = row.get(11)?;
        let parse_success_int: i32 = row.get(14)?;
        let cache_hit_int: i32 = row.get(16)?;

        // Parse UUID, logging warning on error
        let id = uuid::Uuid::parse_str(&id_str).unwrap_or_else(|e| {
            tracing::warn!(error = %e, id_str = %id_str, "Failed to parse UUID from database");
            uuid::Uuid::nil()
        });

        // Parse timestamp, logging warning on error
        let timestamp = chrono::DateTime::parse_from_rfc3339(&timestamp_str)
            .map(|dt| dt.with_timezone(&Utc))
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, timestamp_str = %timestamp_str, "Failed to parse timestamp from database");
                Utc::now()
            });

        // Parse claims JSON, logging warning on error
        let parsed_claims = serde_json::from_str(&parsed_claims_json).unwrap_or_else(|e| {
            tracing::warn!(error = %e, "Failed to parse claims JSON from database");
            Vec::new()
        });
        let final_claims = serde_json::from_str(&final_claims_json).unwrap_or_else(|e| {
            tracing::warn!(error = %e, "Failed to parse final claims JSON from database");
            Vec::new()
        });

        Ok(Observation {
            id,
            timestamp,
            prompt_version: row.get(2)?,
            prompt_hash: row.get(3)?,
            model: row.get(4)?,
            input_hash: row.get(5)?,
            file_path: row.get(6)?,
            language: row.get(7)?,
            content_length: row.get(8)?,
            raw_response: row.get(9)?,
            parsed_claims,
            final_claims,
            input_tokens: row.get(12)?,
            output_tokens: row.get(13)?,
            parse_success: parse_success_int != 0,
            parse_error: row.get(15)?,
            cache_hit: cache_hit_int != 0,
            latency_ms: row.get(17)?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use uuid::Uuid;

    fn make_test_observation() -> Observation {
        Observation {
            id: Uuid::new_v4(),
            timestamp: Utc::now(),
            prompt_version: "v1.0.0".to_string(),
            prompt_hash: "abc123".to_string(),
            model: "gemini-3-flash-preview".to_string(),
            input_hash: "def456".to_string(),
            file_path: "src/auth/login.rs".to_string(),
            language: "rust".to_string(),
            content_length: 1000,
            raw_response: r#"{"claims": []}"#.to_string(),
            parsed_claims: vec![],
            final_claims: vec![],
            input_tokens: 500,
            output_tokens: 100,
            parse_success: true,
            parse_error: None,
            cache_hit: false,
            latency_ms: 1500,
        }
    }

    #[test]
    fn test_database_creation() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let db_path = temp_dir.path().join("test.db");

        let db = EvalDatabase::open(&db_path).expect("open database");
        assert_eq!(db.count().expect("count"), 0);
    }

    #[test]
    fn test_insert_and_count() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let db_path = temp_dir.path().join("test.db");

        let db = EvalDatabase::open(&db_path).expect("open database");

        let obs = make_test_observation();
        db.insert(&obs).expect("insert observation");

        assert_eq!(db.count().expect("count"), 1);
    }

    #[test]
    fn test_get_by_prompt_hash() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let db_path = temp_dir.path().join("test.db");

        let db = EvalDatabase::open(&db_path).expect("open database");

        // Insert two observations with same prompt hash
        let mut obs1 = make_test_observation();
        obs1.prompt_hash = "same_hash".to_string();
        db.insert(&obs1).expect("insert obs1");

        let mut obs2 = make_test_observation();
        obs2.prompt_hash = "same_hash".to_string();
        db.insert(&obs2).expect("insert obs2");

        // Insert one with different hash
        let mut obs3 = make_test_observation();
        obs3.prompt_hash = "different_hash".to_string();
        db.insert(&obs3).expect("insert obs3");

        let results = db.get_by_prompt_hash("same_hash", 10).expect("get by hash");
        assert_eq!(results.len(), 2);
    }

    #[test]
    fn test_retention_enforcement() {
        let temp_dir = TempDir::new().expect("create temp dir");
        let db_path = temp_dir.path().join("test.db");

        let db = EvalDatabase::open(&db_path).expect("open database");

        // Insert 5 observations
        for _ in 0..5 {
            let obs = make_test_observation();
            db.insert(&obs).expect("insert");
        }

        assert_eq!(db.count().expect("count"), 5);

        // With retention of 0 days and max_count of 3, should delete 2
        let deleted = db.enforce_retention(0, 3).expect("enforce retention");
        assert_eq!(deleted, 2);
        assert_eq!(db.count().expect("count after retention"), 3);
    }
}
