//! Per-post engagement repository: unique views, 1–5 ratings, and messages.
//!
//! Mirrors [`crate::database::auth::AuthRepository`]: it holds a
//! [`DatabaseConnection`], dispatches backend-specific SQL on
//! [`DatabaseConnection::database_type`], and marshals values through
//! [`DatabaseParam`]. Visitor identity is an opaque `visitor_id` — the
//! first-party `vid` cookie minted by the request handler — so no raw IP or
//! user-agent is ever persisted.
//!
//! Aggregation note: [`DatabaseRow`](crate::database::DatabaseRow) exposes no
//! floating-point getter, so the rating mean is computed in Rust from `SUM` and
//! `COUNT` (both integers on SQLite and PostgreSQL) rather than read from
//! `AVG()`.

use anyhow::Result;
use chrono::{DateTime, Utc};
use uuid::Uuid;

use crate::database::connection::{DatabaseConnection, DatabaseParam};
use crate::database::{DatabasePool, DatabaseType};

/// Aggregate rating for a post: arithmetic mean and number of distinct voters.
#[derive(Debug, Clone, PartialEq)]
pub struct RatingSummary {
    /// Mean of all votes, or `0.0` when there are none.
    pub average: f64,
    /// Number of distinct voters (one row per `visitor_id`).
    pub count: i64,
}

/// A message to persist for a post, awaiting moderation or later promotion to a
/// public comment. `status` is always stored as `new`.
#[derive(Debug, Clone)]
pub struct NewPostMessage {
    pub post_id: String,
    pub post_title: String,
    pub lang: String,
    pub name: String,
    pub email: Option<String>,
    pub body: String,
    pub visitor_id: Option<String>,
    /// Whether the visitor authorised publishing the comment alongside the
    /// post.
    pub publish_consent: bool,
    /// When consent was given (proof of consent). `None` unless
    /// `publish_consent`.
    pub consent_at: Option<DateTime<Utc>>,
    /// Client IP at consent time (proof of consent). `None` unless available.
    pub consent_ip: Option<String>,
}

/// Database-agnostic repository for per-post engagement.
#[derive(Debug, Clone)]
pub struct EngagementRepository {
    database: DatabaseConnection,
}

impl EngagementRepository {
    /// Create a repository over an existing connection.
    pub fn new(database: DatabaseConnection) -> Self {
        Self { database }
    }

    /// Create from a database pool (convenience, mirrors `AuthRepository`).
    pub fn from_pool(pool: &DatabasePool) -> Self {
        Self::new(DatabaseConnection::from_pool(pool))
    }

    /// The backend this repository talks to.
    pub fn database_type(&self) -> DatabaseType {
        self.database.database_type()
    }

    // ── Views ──────────────────────────────────────────────────────────────

    /// Record a view of `post_id` in `lang` by `visitor_id`, then return the
    /// resulting unique-view count for `(post_id, lang)`.
    ///
    /// Idempotent per `(post_id, lang, visitor_id)`: a repeat view by the same
    /// visitor leaves the count unchanged (`INSERT … ON CONFLICT DO NOTHING`).
    pub async fn record_view(&self, post_id: &str, lang: &str, visitor_id: &str) -> Result<i64> {
        let insert = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "INSERT INTO post_view (post_id, lang, visitor_id, first_seen) VALUES ($1, $2, $3, \
                 $4) ON CONFLICT (post_id, lang, visitor_id) DO NOTHING"
            }
            DatabaseType::SQLite => {
                "INSERT OR IGNORE INTO post_view (post_id, lang, visitor_id, first_seen) VALUES \
                 (?1, ?2, ?3, ?4)"
            }
        };
        self.database
            .execute(
                insert,
                &[
                    DatabaseParam::String(post_id.to_string()),
                    DatabaseParam::String(lang.to_string()),
                    DatabaseParam::String(visitor_id.to_string()),
                    DatabaseParam::DateTime(Utc::now()),
                ],
            )
            .await?;
        self.view_count(post_id, lang).await
    }

    /// Unique-view count for `(post_id, lang)`.
    pub async fn view_count(&self, post_id: &str, lang: &str) -> Result<i64> {
        let query = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "SELECT COUNT(*) AS n FROM post_view WHERE post_id = $1 AND lang = $2"
            }
            DatabaseType::SQLite => {
                "SELECT COUNT(*) AS n FROM post_view WHERE post_id = ?1 AND lang = ?2"
            }
        };
        let row = self
            .database
            .fetch_one(
                query,
                &[
                    DatabaseParam::String(post_id.to_string()),
                    DatabaseParam::String(lang.to_string()),
                ],
            )
            .await?;
        row.get_i64("n")
    }

    // ── Ratings ────────────────────────────────────────────────────────────

    /// Upsert a 1–5 rating for `(post_id, visitor_id)`, then return the updated
    /// aggregate. A repeat vote by the same visitor updates the existing value
    /// rather than adding a row.
    ///
    /// Out-of-range values are rejected before the query runs; the SQL `CHECK`
    /// constraint is the defence-in-depth backstop.
    pub async fn record_rating(
        &self,
        post_id: &str,
        visitor_id: &str,
        value: i32,
    ) -> Result<RatingSummary> {
        if !(1..=5).contains(&value) {
            anyhow::bail!("rating value must be between 1 and 5, got {value}");
        }
        let upsert = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "INSERT INTO post_rating (post_id, visitor_id, value, updated_at) VALUES ($1, $2, \
                 $3, $4) ON CONFLICT (post_id, visitor_id) DO UPDATE SET value = EXCLUDED.value, \
                 updated_at = EXCLUDED.updated_at"
            }
            DatabaseType::SQLite => {
                "INSERT INTO post_rating (post_id, visitor_id, value, updated_at) VALUES (?1, ?2, \
                 ?3, ?4) ON CONFLICT (post_id, visitor_id) DO UPDATE SET value = excluded.value, \
                 updated_at = excluded.updated_at"
            }
        };
        self.database
            .execute(
                upsert,
                &[
                    DatabaseParam::String(post_id.to_string()),
                    DatabaseParam::String(visitor_id.to_string()),
                    DatabaseParam::I32(value),
                    DatabaseParam::DateTime(Utc::now()),
                ],
            )
            .await?;
        self.rating_summary(post_id).await
    }

    /// Aggregate rating for a post. Returns `{ average: 0.0, count: 0 }` when
    /// there are no votes.
    pub async fn rating_summary(&self, post_id: &str) -> Result<RatingSummary> {
        let query = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "SELECT COUNT(*) AS n, COALESCE(SUM(value), 0) AS s FROM post_rating WHERE post_id \
                 = $1"
            }
            DatabaseType::SQLite => {
                "SELECT COUNT(*) AS n, COALESCE(SUM(value), 0) AS s FROM post_rating WHERE post_id \
                 = ?1"
            }
        };
        let row = self
            .database
            .fetch_one(query, &[DatabaseParam::String(post_id.to_string())])
            .await?;
        let count = row.get_i64("n")?;
        let sum = row.get_i64("s")?;
        let average = if count > 0 {
            sum as f64 / count as f64
        } else {
            0.0
        };
        Ok(RatingSummary { average, count })
    }

    /// The value this visitor rated the post, if any. Used to highlight the
    /// caller's own star on load.
    pub async fn visitor_rating(&self, post_id: &str, visitor_id: &str) -> Result<Option<i32>> {
        let query = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "SELECT value FROM post_rating WHERE post_id = $1 AND visitor_id = $2"
            }
            DatabaseType::SQLite => {
                "SELECT value FROM post_rating WHERE post_id = ?1 AND visitor_id = ?2"
            }
        };
        let row = self
            .database
            .fetch_optional(
                query,
                &[
                    DatabaseParam::String(post_id.to_string()),
                    DatabaseParam::String(visitor_id.to_string()),
                ],
            )
            .await?;
        match row {
            Some(r) => Ok(Some(r.get_i32("value")?)),
            None => Ok(None),
        }
    }

    // ── Messages ───────────────────────────────────────────────────────────

    /// Persist a `new`-status message for a post and return its generated id.
    /// Persistence is independent of owner notification, so a message is never
    /// lost even when email delivery is disabled.
    pub async fn insert_message(&self, msg: &NewPostMessage) -> Result<Uuid> {
        let id = Uuid::new_v4();
        // Store the id as text: `post_message.id` is TEXT on both backends, so
        // bind a String rather than a native UUID to avoid backend type coercion.
        let query = match self.database.database_type() {
            DatabaseType::PostgreSQL => {
                "INSERT INTO post_message (id, post_id, post_title, lang, name, email, body, \
                 status, visitor_id, created_at, publish_consent, consent_at, consent_ip) VALUES \
                 ($1, $2, $3, $4, $5, $6, $7, 'new', $8, $9, $10, $11, $12)"
            }
            DatabaseType::SQLite => {
                "INSERT INTO post_message (id, post_id, post_title, lang, name, email, body, \
                 status, visitor_id, created_at, publish_consent, consent_at, consent_ip) VALUES \
                 (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'new', ?8, ?9, ?10, ?11, ?12)"
            }
        };
        self.database
            .execute(
                query,
                &[
                    DatabaseParam::String(id.to_string()),
                    DatabaseParam::String(msg.post_id.clone()),
                    DatabaseParam::String(msg.post_title.clone()),
                    DatabaseParam::String(msg.lang.clone()),
                    DatabaseParam::String(msg.name.clone()),
                    DatabaseParam::OptionalString(msg.email.clone()),
                    DatabaseParam::String(msg.body.clone()),
                    DatabaseParam::OptionalString(msg.visitor_id.clone()),
                    DatabaseParam::DateTime(Utc::now()),
                    DatabaseParam::Bool(msg.publish_consent),
                    DatabaseParam::OptionalDateTime(msg.consent_at),
                    DatabaseParam::OptionalString(msg.consent_ip.clone()),
                ],
            )
            .await?;
        Ok(id)
    }

    /// Number of messages stored for a post, across all statuses.
    pub async fn message_count(&self, post_id: &str) -> Result<i64> {
        let query = match self.database.database_type() {
            DatabaseType::PostgreSQL => "SELECT COUNT(*) AS n FROM post_message WHERE post_id = $1",
            DatabaseType::SQLite => "SELECT COUNT(*) AS n FROM post_message WHERE post_id = ?1",
        };
        let row = self
            .database
            .fetch_one(query, &[DatabaseParam::String(post_id.to_string())])
            .await?;
        row.get_i64("n")
    }
}

#[cfg(test)]
mod tests {
    use sqlx::sqlite::SqlitePoolOptions;

    use super::*;

    /// A single-connection in-memory SQLite repo with the engagement schema
    /// applied. `max_connections(1)` keeps every query on the one connection so
    /// the in-memory database is shared across calls.
    async fn mem_repo() -> EngagementRepository {
        let pool = SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .expect("open in-memory sqlite");

        for stmt in [
            "CREATE TABLE post_view (post_id TEXT NOT NULL, lang TEXT NOT NULL, visitor_id TEXT \
             NOT NULL, first_seen TEXT NOT NULL, PRIMARY KEY (post_id, lang, visitor_id));",
            "CREATE TABLE post_rating (post_id TEXT NOT NULL, visitor_id TEXT NOT NULL, value \
             INTEGER NOT NULL CHECK (value BETWEEN 1 AND 5), updated_at TEXT NOT NULL, PRIMARY \
             KEY (post_id, visitor_id));",
            "CREATE TABLE post_message (id TEXT PRIMARY KEY, post_id TEXT NOT NULL, post_title \
             TEXT NOT NULL, lang TEXT NOT NULL, name TEXT NOT NULL, email TEXT, body TEXT NOT \
             NULL, status TEXT NOT NULL DEFAULT 'new', visitor_id TEXT, created_at TEXT NOT NULL, \
             publish_consent INTEGER NOT NULL DEFAULT 0, consent_at TEXT, consent_ip TEXT);",
        ] {
            sqlx::query(stmt).execute(&pool).await.expect("apply ddl");
        }

        EngagementRepository::from_pool(&DatabasePool::SQLite(pool))
    }

    #[tokio::test]
    async fn view_counts_unique_visitors_and_scopes_by_lang() {
        let repo = mem_repo().await;

        // Same visitor, same post/lang → counts once.
        assert_eq!(repo.record_view("p1", "en", "vA").await.unwrap(), 1);
        assert_eq!(repo.record_view("p1", "en", "vA").await.unwrap(), 1);

        // A different visitor increments.
        assert_eq!(repo.record_view("p1", "en", "vB").await.unwrap(), 2);

        // A different language is a separate bucket.
        assert_eq!(repo.record_view("p1", "es", "vA").await.unwrap(), 1);
        assert_eq!(repo.view_count("p1", "en").await.unwrap(), 2);
    }

    #[tokio::test]
    async fn rating_upsert_updates_in_place() {
        let repo = mem_repo().await;

        // First vote.
        let s = repo.record_rating("p1", "vA", 5).await.unwrap();
        assert_eq!(s.count, 1);
        assert_eq!(s.average, 5.0);

        // Same visitor re-rates: updates, does not duplicate.
        let s = repo.record_rating("p1", "vA", 3).await.unwrap();
        assert_eq!(s.count, 1);
        assert_eq!(s.average, 3.0);

        // Second visitor: avg (3 + 5) / 2 = 4.0.
        let s = repo.record_rating("p1", "vB", 5).await.unwrap();
        assert_eq!(s.count, 2);
        assert_eq!(s.average, 4.0);

        // Each visitor can read back their own current vote.
        assert_eq!(repo.visitor_rating("p1", "vA").await.unwrap(), Some(3));
        assert_eq!(repo.visitor_rating("p1", "vB").await.unwrap(), Some(5));
        assert_eq!(repo.visitor_rating("p1", "vC").await.unwrap(), None);
    }

    #[tokio::test]
    async fn rating_rejects_out_of_range() {
        let repo = mem_repo().await;
        assert!(repo.record_rating("p1", "vA", 0).await.is_err());
        assert!(repo.record_rating("p1", "vA", 6).await.is_err());
        assert!(repo.record_rating("p1", "vA", -1).await.is_err());
        // Nothing persisted after rejected writes.
        assert_eq!(repo.rating_summary("p1").await.unwrap().count, 0);
    }

    #[tokio::test]
    async fn empty_rating_summary_is_zero() {
        let repo = mem_repo().await;
        let s = repo.rating_summary("nope").await.unwrap();
        assert_eq!(s.count, 0);
        assert_eq!(s.average, 0.0);
    }

    #[tokio::test]
    async fn message_persists_and_counts() {
        let repo = mem_repo().await;
        let msg = NewPostMessage {
            post_id: "p1".to_string(),
            post_title: "Hello".to_string(),
            lang: "en".to_string(),
            name: "Ada".to_string(),
            email: Some("ada@example.com".to_string()),
            body: "nice post".to_string(),
            visitor_id: Some("vA".to_string()),
            publish_consent: true,
            consent_at: Some(Utc::now()),
            consent_ip: Some("203.0.113.7".to_string()),
        };
        let id = repo.insert_message(&msg).await.unwrap();
        assert!(!id.is_nil());
        assert_eq!(repo.message_count("p1").await.unwrap(), 1);
        assert_eq!(repo.message_count("other").await.unwrap(), 0);

        // A second message with no email still stores.
        let mut anon = msg.clone();
        anon.email = None;
        anon.visitor_id = None;
        repo.insert_message(&anon).await.unwrap();
        assert_eq!(repo.message_count("p1").await.unwrap(), 2);
    }
}
