//! Read access over the `projects` registry (§7 multi-repo ownership).
//!
//! A project is registered by the APPLY path — an upsert-by-slug inside the
//! reconcile transaction (see `apply_service`), so a claim registers the
//! project atomically with the write. This repo is the READ side only, used
//! for ownership visibility (`pic projects ls`, the `pic groups ls` owner
//! column) and tests. Writes deliberately do not live here.

use async_trait::async_trait;
use picloud_shared::{Project, ProjectId};
use sqlx::PgPool;
use uuid::Uuid;

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

#[async_trait]
pub trait ProjectRepository: Send + Sync {
    /// Every project on the instance, ordered by slug.
    async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError>;
    /// Every project paired with how many group nodes it owns — backs
    /// `pic projects ls`. One GROUP BY instead of N per-project counts.
    async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError>;
    async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>;
    async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>;
    /// §3 M3 (hermetic gate): the persisted per-env approval policy for the
    /// project with this slug — `env_name -> confirm`. Empty when the project is
    /// unregistered or declares no gated envs. The apply/plan path unions this
    /// with the request's declared policy so an omitted policy can't bypass a
    /// gate a prior apply persisted (see `apply_api::env_gate_check`).
    async fn get_environments_by_slug(
        &self,
        slug: &str,
    ) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
    /// §3 M3 (hermetic gate, server-authoritative): the persisted per-env
    /// approval policy for the project with this id. Same shape as
    /// `get_environments_by_slug`, but keyed by the id the server RESOLVED from
    /// the target node's nearest-claimed ancestor — so a client that omits or
    /// spoofs `[project]` can't dodge a gate the owning project established.
    async fn get_environments_by_id(
        &self,
        id: ProjectId,
    ) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
}

pub struct PostgresProjectRepository {
    pool: PgPool,
}

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

const PROJECT_COLS: &str = "id, slug, name, created_by, created_at";

#[async_trait]
impl ProjectRepository for PostgresProjectRepository {
    async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError> {
        let rows = sqlx::query_as::<_, ProjectCountRow>(
            "SELECT p.id, p.slug, p.name, p.created_by, p.created_at, \
                    COUNT(g.id) AS owned_groups \
             FROM projects p LEFT JOIN groups g ON g.owner_project = p.id \
             GROUP BY p.id ORDER BY p.slug",
        )
        .fetch_all(&self.pool)
        .await?;
        Ok(rows
            .into_iter()
            .map(|r| (r.project.into(), r.owned_groups))
            .collect())
    }

    async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError> {
        let rows = sqlx::query_as::<_, ProjectRow>(&format!(
            "SELECT {PROJECT_COLS} FROM projects ORDER BY slug"
        ))
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError> {
        let row = sqlx::query_as::<_, ProjectRow>(&format!(
            "SELECT {PROJECT_COLS} FROM projects WHERE id = $1"
        ))
        .bind(id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError> {
        let row = sqlx::query_as::<_, ProjectRow>(&format!(
            "SELECT {PROJECT_COLS} FROM projects WHERE slug = $1"
        ))
        .bind(slug)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn get_environments_by_slug(
        &self,
        slug: &str,
    ) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError> {
        let rows = sqlx::query_as::<_, (String, bool)>(
            "SELECT pe.env_name, pe.confirm \
             FROM project_environments pe \
             JOIN projects p ON p.id = pe.project_id \
             WHERE p.slug = $1",
        )
        .bind(slug)
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().collect())
    }

    async fn get_environments_by_id(
        &self,
        id: ProjectId,
    ) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError> {
        let rows = sqlx::query_as::<_, (String, bool)>(
            "SELECT env_name, confirm FROM project_environments WHERE project_id = $1",
        )
        .bind(id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().collect())
    }
}

#[derive(sqlx::FromRow)]
struct ProjectRow {
    id: Uuid,
    slug: String,
    name: String,
    created_by: Option<Uuid>,
    created_at: chrono::DateTime<chrono::Utc>,
}

/// `list_with_counts` row: a project flattened with its owned-group count.
#[derive(sqlx::FromRow)]
struct ProjectCountRow {
    #[sqlx(flatten)]
    project: ProjectRow,
    owned_groups: i64,
}

impl From<ProjectRow> for Project {
    fn from(r: ProjectRow) -> Self {
        Self {
            id: r.id.into(),
            slug: r.slug,
            name: r.name,
            created_by: r.created_by.map(Into::into),
            created_at: r.created_at,
        }
    }
}
