//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of
//! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`.
//! The `find` query is built by the shared [`crate::docs_repo::build_find_query`]
//! (parameterized on table + owner column), so the security-sensitive filter
//! SQL has a single source. Authorization, group resolution, value validation,
//! and event policy live one layer up in `GroupDocsServiceImpl`.

use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{DocId, DocRow, DocsListPage, GroupId};
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;

use crate::docs_filter::DocsFilter;
use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError};

#[derive(Debug, thiserror::Error)]
pub enum GroupDocsRepoError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),

    #[error("invalid pagination cursor")]
    InvalidCursor,
}

impl From<DocsRepoError> for GroupDocsRepoError {
    fn from(e: DocsRepoError) -> Self {
        match e {
            DocsRepoError::Db(e) => Self::Db(e),
            DocsRepoError::InvalidCursor => Self::InvalidCursor,
        }
    }
}

#[async_trait]
pub trait GroupDocsRepo: Send + Sync {
    async fn create(
        &self,
        group_id: GroupId,
        collection: &str,
        data: Value,
    ) -> Result<DocRow, GroupDocsRepoError>;

    async fn get(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
    ) -> Result<Option<DocRow>, GroupDocsRepoError>;

    async fn find(
        &self,
        group_id: GroupId,
        collection: &str,
        filter: &DocsFilter,
    ) -> Result<Vec<DocRow>, GroupDocsRepoError>;

    /// Returns the previous data (for the would-be event), `None` if missing.
    async fn update(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<Option<Value>, GroupDocsRepoError>;

    async fn delete(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
    ) -> Result<Option<Value>, GroupDocsRepoError>;

    async fn list(
        &self,
        group_id: GroupId,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<DocsListPage, GroupDocsRepoError>;

    /// §11.6 quota: total doc count across the group's shared-docs collections.
    /// Default `Ok(0)` so non-Postgres impls skip the quota check.
    async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
        let _ = group_id;
        Ok(0)
    }

    /// §11.6 M4 quota: total stored JSON bytes across the group's shared-docs
    /// collections (`octet_length(data::text)`). Default `Ok(0)`.
    async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
        let _ = group_id;
        Ok(0)
    }

    /// §11.6 M4 quota: the PROJECTED total stored bytes for the group AFTER
    /// writing `new_data` — current SUM, minus the existing doc's bytes
    /// (`replacing = Some(id)` on update; `None` on create), plus the new data's
    /// bytes. Computed entirely in SQL so ALL three terms use the SAME canonical
    /// metric (`octet_length(data::text)`); the Rust-side subtract/add previously
    /// mixed the DB canonical SUM with a `serde_json` compact length, drifting the
    /// ceiling permissive. Default `Ok(0)`.
    async fn projected_total_bytes(
        &self,
        group_id: GroupId,
        replacing: Option<DocId>,
        new_data: &serde_json::Value,
    ) -> Result<u64, GroupDocsRepoError> {
        let _ = (group_id, replacing, new_data);
        Ok(0)
    }
}

pub struct PostgresGroupDocsRepo {
    pool: PgPool,
}

impl PostgresGroupDocsRepo {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;

#[async_trait]
impl GroupDocsRepo for PostgresGroupDocsRepo {
    async fn create(
        &self,
        group_id: GroupId,
        collection: &str,
        data: Value,
    ) -> Result<DocRow, GroupDocsRepoError> {
        create_on(&self.pool, group_id, collection, data).await
    }

    async fn get(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
    ) -> Result<Option<DocRow>, GroupDocsRepoError> {
        let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
            "SELECT data, created_at, updated_at FROM group_docs \
             WHERE group_id = $1 AND collection = $2 AND id = $3",
        )
        .bind(group_id.into_inner())
        .bind(collection)
        .bind(id)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(|(data, created_at, updated_at)| DocRow {
            id,
            data,
            created_at,
            updated_at,
        }))
    }

    async fn find(
        &self,
        group_id: GroupId,
        collection: &str,
        filter: &DocsFilter,
    ) -> Result<Vec<DocRow>, GroupDocsRepoError> {
        let mut qb = build_find_query(
            "group_docs",
            "group_id",
            group_id.into_inner(),
            collection,
            filter,
        );
        let rows = qb.build().fetch_all(&self.pool).await?;
        rows.into_iter()
            .map(row_to_doc)
            .collect::<Result<Vec<_>, _>>()
            .map_err(Into::into)
    }

    async fn update(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<Option<Value>, GroupDocsRepoError> {
        update_on(&self.pool, group_id, collection, id, data).await
    }

    async fn delete(
        &self,
        group_id: GroupId,
        collection: &str,
        id: DocId,
    ) -> Result<Option<Value>, GroupDocsRepoError> {
        delete_on(&self.pool, group_id, collection, id).await
    }

    async fn list(
        &self,
        group_id: GroupId,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<DocsListPage, GroupDocsRepoError> {
        let limit = if limit == 0 {
            DOCS_LIST_DEFAULT_LIMIT
        } else {
            limit.min(DOCS_LIST_MAX_LIMIT)
        };

        let last_id = match cursor {
            Some(c) => Some(decode_cursor(c)?),
            None => None,
        };

        let take = i64::from(limit) + 1;
        let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
            "SELECT id, data, created_at, updated_at FROM group_docs \
             WHERE group_id = $1 AND collection = $2 \
               AND ($3::uuid IS NULL OR id > $3) \
             ORDER BY id ASC \
             LIMIT $4",
        )
        .bind(group_id.into_inner())
        .bind(collection)
        .bind(last_id)
        .bind(take)
        .fetch_all(&self.pool)
        .await?;

        let mut docs: Vec<DocRow> = rows
            .into_iter()
            .map(|(id, data, created_at, updated_at)| DocRow {
                id,
                data,
                created_at,
                updated_at,
            })
            .collect();
        let next_cursor = if docs.len() > limit as usize {
            docs.truncate(limit as usize);
            docs.last().map(|d| encode_cursor(&d.id))
        } else {
            None
        };

        Ok(DocsListPage { docs, next_cursor })
    }

    async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
        count_rows_on(&self.pool, group_id).await
    }

    async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
        let (n,): (i64,) = sqlx::query_as(
            "SELECT COALESCE(SUM(octet_length(data::text)), 0)::BIGINT \
             FROM group_docs WHERE group_id = $1",
        )
        .bind(group_id.into_inner())
        .fetch_one(&self.pool)
        .await?;
        Ok(u64::try_from(n).unwrap_or(0))
    }

    async fn projected_total_bytes(
        &self,
        group_id: GroupId,
        replacing: Option<DocId>,
        new_data: &serde_json::Value,
    ) -> Result<u64, GroupDocsRepoError> {
        projected_total_bytes_on(&self.pool, group_id, replacing, new_data).await
    }
}

// ----------------------------------------------------------------------------
// Connection-scoped operations
//
// Generic over the executor so the same SQL serves the pooled trait methods
// above and `crate::atomic_write`, which runs the per-group advisory lock, the
// quota reads, the write, and the shared-trigger fan-out on ONE connection
// inside ONE transaction (audit #6/#8).
// ----------------------------------------------------------------------------

pub(crate) async fn create_on<'c, E>(
    exec: E,
    group_id: GroupId,
    collection: &str,
    data: Value,
) -> Result<DocRow, GroupDocsRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let id = Uuid::new_v4();
    let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
        "INSERT INTO group_docs (group_id, collection, id, data) \
         VALUES ($1, $2, $3, $4) \
         RETURNING created_at, updated_at",
    )
    .bind(group_id.into_inner())
    .bind(collection)
    .bind(id)
    .bind(&data)
    .fetch_one(exec)
    .await?;
    Ok(DocRow {
        id,
        data,
        created_at: row.0,
        updated_at: row.1,
    })
}

/// Returns the previous data (for the update event), `None` if the doc is absent.
pub(crate) async fn update_on<'c, E>(
    exec: E,
    group_id: GroupId,
    collection: &str,
    id: DocId,
    data: Value,
) -> Result<Option<Value>, GroupDocsRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: Option<(Option<Value>,)> = sqlx::query_as(
        "WITH prev AS ( \
            SELECT data FROM group_docs \
            WHERE group_id = $1 AND collection = $2 AND id = $3 \
         ), \
         updated AS ( \
            UPDATE group_docs SET data = $4, updated_at = NOW() \
            WHERE group_id = $1 AND collection = $2 AND id = $3 \
            RETURNING 1 \
         ) \
         SELECT (SELECT data FROM prev) FROM updated",
    )
    .bind(group_id.into_inner())
    .bind(collection)
    .bind(id)
    .bind(&data)
    .fetch_optional(exec)
    .await?;
    Ok(row.and_then(|(v,)| v))
}

pub(crate) async fn delete_on<'c, E>(
    exec: E,
    group_id: GroupId,
    collection: &str,
    id: DocId,
) -> Result<Option<Value>, GroupDocsRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: Option<(Value,)> = sqlx::query_as(
        "DELETE FROM group_docs \
         WHERE group_id = $1 AND collection = $2 AND id = $3 \
         RETURNING data",
    )
    .bind(group_id.into_inner())
    .bind(collection)
    .bind(id)
    .fetch_optional(exec)
    .await?;
    Ok(row.map(|(v,)| v))
}

/// Total doc count across the group's shared-docs collections (§11.6 row quota).
pub(crate) async fn count_rows_on<'c, E>(
    exec: E,
    group_id: GroupId,
) -> Result<u64, GroupDocsRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
        .bind(group_id.into_inner())
        .fetch_one(exec)
        .await?;
    Ok(u64::try_from(n).unwrap_or(0))
}

/// §11.6 M4 quota: the PROJECTED total stored bytes for the group AFTER writing
/// `new_data` — current SUM, minus the replaced doc's bytes (`replacing =
/// Some(id)` on update, `None` on create), plus the new data's. Computed
/// entirely in SQL so all three terms use the SAME canonical metric
/// (`octet_length(data::text)`).
pub(crate) async fn projected_total_bytes_on<'c, E>(
    exec: E,
    group_id: GroupId,
    replacing: Option<DocId>,
    new_data: &serde_json::Value,
) -> Result<u64, GroupDocsRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    // The new data is bound as compact TEXT and re-parsed through `::jsonb::text`
    // so PG measures it in the same canonical form it stores. `replacing = None`
    // (create) → the subtrahend row never matches → 0.
    let new_text = serde_json::to_string(new_data).unwrap_or_else(|_| "null".to_string());
    let (n,): (i64,) = sqlx::query_as(
        "SELECT ( \
            COALESCE((SELECT SUM(octet_length(data::text)) \
                      FROM group_docs WHERE group_id = $1), 0) \
          - COALESCE((SELECT octet_length(data::text) \
                      FROM group_docs WHERE group_id = $1 AND id = $2), 0) \
          + octet_length($3::jsonb::text) \
         )::BIGINT",
    )
    .bind(group_id.into_inner())
    .bind(replacing)
    .bind(new_text)
    .fetch_one(exec)
    .await?;
    Ok(u64::try_from(n).unwrap_or(0))
}

fn encode_cursor(last_id: &Uuid) -> String {
    URL_SAFE_NO_PAD.encode(last_id.as_bytes())
}

fn decode_cursor(cursor: &str) -> Result<Uuid, GroupDocsRepoError> {
    let bytes = URL_SAFE_NO_PAD
        .decode(cursor)
        .map_err(|_| GroupDocsRepoError::InvalidCursor)?;
    let arr: [u8; 16] = bytes
        .as_slice()
        .try_into()
        .map_err(|_| GroupDocsRepoError::InvalidCursor)?;
    Ok(Uuid::from_bytes(arr))
}
