//! CRUD over the `groups` tree (Phase 2).
//!
//! Groups form a single-parent org tree above apps. Structural mutations
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
//!
//! - **delete = RESTRICT** — refused if the group has child groups or apps
//!   (the DB FKs enforce this; we surface a clean conflict).
//! - **slug-freeze** — `rename` edits name/description only; the slug is
//!   set once at creation and never rewritten.
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
//!   coarse instance-wide advisory lock and refuses a move that would make
//!   a node its own ancestor. A SQL `CHECK` can't express this.
//! - **structure_version** — bumped on every structural mutation so a
//!   future CLI/orchestrator can detect structural drift (§6).

use async_trait::async_trait;
use picloud_shared::{Group, GroupId};
use sqlx::PgPool;
use uuid::Uuid;

/// Instance-wide advisory-lock key for structural group mutations. Coarse
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
/// cycle guard and the `parent_id` write run serialized — two concurrent
/// reparents can't race into a cycle. Distinct from the per-app
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
pub(crate) const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;

/// Well-known slug of the instance root group seeded by migration 0047.
pub const ROOT_GROUP_SLUG: &str = "root";

#[derive(Debug, thiserror::Error)]
pub enum GroupRepositoryError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),
    #[error("not found: {0}")]
    NotFound(GroupId),
    #[error("conflict: {0}")]
    Conflict(String),
}

/// Counts of a group's direct children — used to enforce delete=RESTRICT
/// with an actionable message and to render the tree.
#[derive(Debug, Clone, Copy)]
pub struct GroupChildCounts {
    pub subgroups: i64,
    pub apps: i64,
}

impl GroupChildCounts {
    #[must_use]
    pub fn is_empty(self) -> bool {
        self.subgroups == 0 && self.apps == 0
    }
}

/// §6: create a group INSIDE an existing apply transaction, returning its id.
/// Mirrors [`PostgresGroupRepository::create`]'s error mapping. Used by the
/// declarative tree apply to create a group the manifest declares (by directory
/// nesting) but the server doesn't have yet. `structure_version` starts at the
/// column default (a fresh node has no structural history to bump).
pub(crate) async fn create_group_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    slug: &str,
    name: &str,
    description: Option<&str>,
    parent_id: Option<GroupId>,
) -> Result<GroupId, GroupRepositoryError> {
    let res = sqlx::query_as::<_, (Uuid,)>(
        "INSERT INTO groups (slug, name, description, parent_id) \
         VALUES ($1, $2, $3, $4) RETURNING id",
    )
    .bind(slug)
    .bind(name)
    .bind(description)
    .bind(parent_id.map(GroupId::into_inner))
    .fetch_one(&mut **tx)
    .await;
    match res {
        Ok((id,)) => Ok(id.into()),
        Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
            GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
        ),
        Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
            GroupRepositoryError::Conflict("parent group does not exist".into()),
        ),
        Err(e) => Err(e.into()),
    }
}

/// Reparent a group INSIDE an existing tx (the caller holds
/// [`GROUP_STRUCTURAL_LOCK_KEY`]): the ancestor-walk cycle guard + the
/// `parent_id` write + `structure_version` bump. Used by the imperative
/// `reparent` (its own tx) and §6 M2's declarative structural reconcile (the
/// apply tx). A same-tx just-created ancestor is visible to the cycle walk.
pub(crate) async fn reparent_group_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    id: GroupId,
    new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
    if let Some(parent) = new_parent {
        if parent == id {
            return Err(GroupRepositoryError::Conflict(
                "a group cannot be its own parent".into(),
            ));
        }
        // Cycle guard: walk from the destination up to the root; if we reach
        // `id`, the move would place `id` beneath itself.
        let mut cursor = Some(parent);
        let mut hops = 0u32;
        while let Some(node) = cursor {
            if node == id {
                return Err(GroupRepositoryError::Conflict(
                    "cannot reparent a group beneath one of its own descendants".into(),
                ));
            }
            hops += 1;
            if hops > 64 {
                return Err(GroupRepositoryError::Conflict(
                    "group ancestry exceeds the maximum depth".into(),
                ));
            }
            let parent_of: Option<(Option<Uuid>,)> =
                sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
                    .bind(node.into_inner())
                    .fetch_optional(&mut **tx)
                    .await?;
            match parent_of {
                Some((p,)) => cursor = p.map(GroupId::from),
                None => {
                    return Err(GroupRepositoryError::Conflict(
                        "destination parent group does not exist".into(),
                    ));
                }
            }
        }
    }
    let row = sqlx::query_as::<_, GroupRow>(&format!(
        "UPDATE groups SET \
             parent_id = $2, \
             structure_version = structure_version + 1, \
             updated_at = NOW() \
         WHERE id = $1 \
         RETURNING {GROUP_COLS}"
    ))
    .bind(id.into_inner())
    .bind(new_parent.map(GroupId::into_inner))
    .fetch_optional(&mut **tx)
    .await?;
    row.map(Into::into)
        .ok_or(GroupRepositoryError::NotFound(id))
}

/// §6: resolve a group slug → id inside the apply tx, so a group CREATED earlier
/// in the same transaction is visible (a pool read would miss the uncommitted
/// row). `None` if absent.
pub(crate) async fn read_group_id_by_slug_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    slug: &str,
) -> Result<Option<GroupId>, GroupRepositoryError> {
    let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM groups WHERE slug = $1")
        .bind(slug)
        .fetch_optional(&mut **tx)
        .await?;
    Ok(row.map(|(id,)| id.into()))
}

/// §6 M2: read a group's `(id, parent_id)` by slug inside the tx — the input to
/// the structural-divergence check (server parent vs manifest parent). `None`
/// if the group doesn't exist (it is to-create).
pub(crate) async fn read_group_node_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    slug: &str,
) -> Result<Option<(GroupId, Option<GroupId>)>, GroupRepositoryError> {
    let row: Option<(Uuid, Option<Uuid>)> =
        sqlx::query_as("SELECT id, parent_id FROM groups WHERE slug = $1")
            .bind(slug)
            .fetch_optional(&mut **tx)
            .await?;
    Ok(row.map(|(id, parent)| (id.into(), parent.map(GroupId::from))))
}

#[async_trait]
pub trait GroupRepository: Send + Sync {
    /// Every group on the instance, ordered by name. The tree is small
    /// (org structure), so callers assemble the hierarchy in memory.
    async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
    /// Every group paired with its owning project's slug (`None` when the node
    /// is unclaimed — no manifest owns it). One LEFT JOIN instead of an N+1
    /// per-group project lookup; backs `pic groups ls`' owner column (§7).
    async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError>;
    async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
    async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
    /// Direct children groups of `parent`.
    async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
    /// The node plus its ancestors up to the root, nearest-first. Used for
    /// path display and as the reparent cycle-guard input.
    async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
    async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
    async fn create(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        parent_id: Option<GroupId>,
    ) -> Result<Group, GroupRepositoryError>;
    /// Edit display fields only — the slug is frozen at creation. Bumps
    /// `structure_version`.
    async fn rename(
        &self,
        id: GroupId,
        name: Option<&str>,
        description: Option<Option<&str>>,
    ) -> Result<Group, GroupRepositoryError>;
    /// Move `id` under `new_parent` (or to root if `None`). Runs the
    /// ancestor-walk cycle guard under a coarse structural lock and bumps
    /// `structure_version`. Refuses a move that would create a cycle.
    async fn reparent(
        &self,
        id: GroupId,
        new_parent: Option<GroupId>,
    ) -> Result<Group, GroupRepositoryError>;
    /// Delete an empty group (delete = RESTRICT). Refused with a clean
    /// conflict if it still has child groups or apps.
    async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
}

pub struct PostgresGroupRepository {
    pool: PgPool,
}

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

const GROUP_COLS: &str = "id, parent_id, slug, name, description, structure_version, created_at, \
     updated_at, owner_project";

#[async_trait]
impl GroupRepository for PostgresGroupRepository {
    async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
        let rows = sqlx::query_as::<_, GroupRow>(&format!(
            "SELECT {GROUP_COLS} FROM groups ORDER BY name"
        ))
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError> {
        // Columns are qualified (`g.`) because `id`/`slug`/`name` exist on both
        // groups and projects — a bare GROUP_COLS would be ambiguous here.
        let rows = sqlx::query_as::<_, GroupWithOwnerRow>(
            "SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
                    g.structure_version, g.created_at, g.updated_at, g.owner_project, \
                    p.slug AS owner_slug \
             FROM groups g LEFT JOIN projects p ON p.id = g.owner_project \
             ORDER BY g.name",
        )
        .fetch_all(&self.pool)
        .await?;
        Ok(rows
            .into_iter()
            .map(|r| (r.group.into(), r.owner_slug))
            .collect())
    }

    async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
        let row = sqlx::query_as::<_, GroupRow>(&format!(
            "SELECT {GROUP_COLS} FROM groups 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<Group>, GroupRepositoryError> {
        let row = sqlx::query_as::<_, GroupRow>(&format!(
            "SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
        ))
        .bind(slug)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
        let rows = sqlx::query_as::<_, GroupRow>(&format!(
            "SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
        ))
        .bind(parent.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
        // Recursive walk node → root, nearest-first. Depth-bounded as a
        // runaway guard (the cycle guard already prevents cycles).
        let rows = sqlx::query_as::<_, GroupRow>(&format!(
            "WITH RECURSIVE chain AS (
                 SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
               UNION ALL
                 SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
                        g.structure_version, g.created_at, g.updated_at, g.owner_project, \
                        c.depth + 1 \
                 FROM groups g JOIN chain c ON g.id = c.parent_id \
                 WHERE c.depth < 64
             )
             SELECT {GROUP_COLS} FROM chain ORDER BY depth"
        ))
        .bind(id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
        let row: (i64, i64) = sqlx::query_as(
            "SELECT \
                 (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
                 (SELECT COUNT(*) FROM apps WHERE group_id = $1)",
        )
        .bind(id.into_inner())
        .fetch_one(&self.pool)
        .await?;
        Ok(GroupChildCounts {
            subgroups: row.0,
            apps: row.1,
        })
    }

    async fn create(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        parent_id: Option<GroupId>,
    ) -> Result<Group, GroupRepositoryError> {
        let res = sqlx::query_as::<_, GroupRow>(&format!(
            "INSERT INTO groups (slug, name, description, parent_id) \
             VALUES ($1, $2, $3, $4) \
             RETURNING {GROUP_COLS}"
        ))
        .bind(slug)
        .bind(name)
        .bind(description)
        .bind(parent_id.map(GroupId::into_inner))
        .fetch_one(&self.pool)
        .await;
        match res {
            Ok(row) => Ok(row.into()),
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
                GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
            ),
            Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
                GroupRepositoryError::Conflict("parent group does not exist".into()),
            ),
            Err(e) => Err(e.into()),
        }
    }

    async fn rename(
        &self,
        id: GroupId,
        name: Option<&str>,
        description: Option<Option<&str>>,
    ) -> Result<Group, GroupRepositoryError> {
        // Slug is intentionally absent from the SET list — it is frozen.
        let row = sqlx::query_as::<_, GroupRow>(&format!(
            "UPDATE groups SET \
                 name = COALESCE($2, name), \
                 description = CASE WHEN $3::bool THEN $4 ELSE description END, \
                 structure_version = structure_version + 1, \
                 updated_at = NOW() \
             WHERE id = $1 \
             RETURNING {GROUP_COLS}"
        ))
        .bind(id.into_inner())
        .bind(name)
        .bind(description.is_some())
        .bind(description.and_then(|d| d))
        .fetch_optional(&self.pool)
        .await?;
        row.map(Into::into)
            .ok_or(GroupRepositoryError::NotFound(id))
    }

    async fn reparent(
        &self,
        id: GroupId,
        new_parent: Option<GroupId>,
    ) -> Result<Group, GroupRepositoryError> {
        let mut tx = self.pool.begin().await?;
        // Coarse structural lock: serialize all structural mutations so the
        // cycle guard + parent write can't interleave with a concurrent
        // reparent and race into a cycle.
        sqlx::query("SELECT pg_advisory_xact_lock($1)")
            .bind(GROUP_STRUCTURAL_LOCK_KEY)
            .execute(&mut *tx)
            .await?;
        let group = reparent_group_tx(&mut tx, id, new_parent).await?;
        tx.commit().await?;
        Ok(group)
    }

    async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
        // Pre-check for a clean message; the FK RESTRICT is the real guard.
        let counts = self.child_counts(id).await?;
        if !counts.is_empty() {
            return Err(GroupRepositoryError::Conflict(format!(
                "group still has {} subgroup(s) and {} app(s); move or delete them first",
                counts.subgroups, counts.apps
            )));
        }
        let res = sqlx::query("DELETE FROM groups WHERE id = $1")
            .bind(id.into_inner())
            .execute(&self.pool)
            .await;
        match res {
            Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
            Ok(_) => Ok(()),
            Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
                // Lost a race with a concurrent child insert.
                Err(GroupRepositoryError::Conflict(
                    "group still has descendants; move or delete them first".into(),
                ))
            }
            Err(e) => Err(e.into()),
        }
    }
}

#[derive(sqlx::FromRow)]
struct GroupRow {
    id: Uuid,
    parent_id: Option<Uuid>,
    slug: String,
    name: String,
    description: Option<String>,
    structure_version: i64,
    created_at: chrono::DateTime<chrono::Utc>,
    updated_at: chrono::DateTime<chrono::Utc>,
    owner_project: Option<Uuid>,
}

/// `list_with_owner` row: a `GroupRow` flattened with its owning project's
/// slug (`NULL` when the group is unclaimed).
#[derive(sqlx::FromRow)]
struct GroupWithOwnerRow {
    #[sqlx(flatten)]
    group: GroupRow,
    owner_slug: Option<String>,
}

impl From<GroupRow> for Group {
    fn from(r: GroupRow) -> Self {
        Self {
            id: r.id.into(),
            parent_id: r.parent_id.map(Into::into),
            slug: r.slug,
            name: r.name,
            description: r.description,
            structure_version: r.structure_version,
            owner_project: r.owner_project.map(Into::into),
            created_at: r.created_at,
            updated_at: r.updated_at,
        }
    }
}
