//! CRUD over the `apps` and `app_slug_history` tables.
//!
//! Slug validation (regex, reserved-word check) lives in the API
//! handler; this repo enforces only what Postgres enforces (uniqueness,
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
//! that writes the history row in the same transaction.

use async_trait::async_trait;
use picloud_shared::{AdminUserId, App, AppId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;

use crate::repo::ScriptRepositoryError;

/// Result of looking up an app by slug or via the redirect history.
#[derive(Debug, Clone)]
pub struct AppLookup {
    pub app: App,
    /// `true` when the slug was found in `app_slug_history` rather than
    /// directly on `apps`. Dashboards should issue a redirect.
    pub redirected: bool,
}

/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
/// fall through to `app_slug_history` and set `redirected: true` when
/// they hit it.
///
/// Returns `Ok(None)` when nothing matches — callers map that to their
/// own not-found error variant.
///
/// # Errors
/// Propagates any underlying repository error.
pub async fn resolve_app(
    apps: &dyn AppRepository,
    ident: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
    if let Ok(uuid) = ident.parse::<Uuid>() {
        return Ok(apps
            .get_by_id(AppId::from(uuid))
            .await?
            .map(|app| AppLookup {
                app,
                redirected: false,
            }));
    }
    apps.get_by_slug_or_history(ident).await
}

#[async_trait]
pub trait AppRepository: Send + Sync {
    /// Every app on the instance. For owner/admin callers — `member`
    /// users go through `list_for_user`.
    async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
    /// Only apps the user has an `app_members` row for. Drives the
    /// membership-filtered `GET /admin/apps` for `member` callers.
    async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
    /// Apps whose parent is `group_id`. Drives the group detail view.
    async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
    async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
    async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
    async fn get_by_slug_or_history(
        &self,
        slug: &str,
    ) -> Result<Option<AppLookup>, ScriptRepositoryError>;
    async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
    async fn create(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        group_id: GroupId,
    ) -> Result<App, ScriptRepositoryError>;
    /// Create that also consumes a matching `app_slug_history` row, if
    /// any. Used after the operator has confirmed they want to break old
    /// redirects.
    async fn create_with_takeover(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        group_id: GroupId,
    ) -> Result<App, ScriptRepositoryError>;
    async fn update(
        &self,
        id: AppId,
        name: Option<&str>,
        description: Option<Option<&str>>,
    ) -> Result<App, ScriptRepositoryError>;
    /// Rename and record the old slug in `app_slug_history` (so
    /// retired URLs keep redirecting). If `take_over_history` is true,
    /// any existing history row for `new_slug` is consumed.
    async fn rename_slug(
        &self,
        id: AppId,
        new_slug: &str,
        take_over_history: bool,
    ) -> Result<App, ScriptRepositoryError>;
    async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
    /// Delete the app along with all its scripts (which in turn cascades
    /// routes and execution logs via their `script_id` FK). Domains and
    /// app-slug-history rows cascade off the app row itself. Runs in a
    /// single transaction so a partial delete cannot be observed.
    async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
    async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
}

pub struct PostgresAppRepository {
    pool: PgPool,
}

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

#[async_trait]
impl AppRepository for PostgresAppRepository {
    async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
        let rows = sqlx::query_as::<_, AppRow>(
            "SELECT id, slug, name, description, group_id, created_at, updated_at \
             FROM apps ORDER BY name",
        )
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
        let rows = sqlx::query_as::<_, AppRow>(
            "SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
             FROM apps a \
             JOIN app_members m ON m.app_id = a.id \
             WHERE m.user_id = $1 \
             ORDER BY a.name",
        )
        .bind(user_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
        let rows = sqlx::query_as::<_, AppRow>(
            "SELECT id, slug, name, description, group_id, created_at, updated_at \
             FROM apps WHERE group_id = $1 ORDER BY name",
        )
        .bind(group_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
        let row = sqlx::query_as::<_, AppRow>(
            "SELECT id, slug, name, description, group_id, created_at, updated_at \
             FROM apps 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<App>, ScriptRepositoryError> {
        let row = sqlx::query_as::<_, AppRow>(
            "SELECT id, slug, name, description, group_id, created_at, updated_at \
             FROM apps WHERE slug = $1",
        )
        .bind(slug)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn get_by_slug_or_history(
        &self,
        slug: &str,
    ) -> Result<Option<AppLookup>, ScriptRepositoryError> {
        if let Some(app) = self.get_by_slug(slug).await? {
            return Ok(Some(AppLookup {
                app,
                redirected: false,
            }));
        }
        if let Some(app) = self.slug_in_history(slug).await? {
            return Ok(Some(AppLookup {
                app,
                redirected: true,
            }));
        }
        Ok(None)
    }

    async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
        let row = sqlx::query_as::<_, AppRow>(
            "SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
             FROM app_slug_history h \
             JOIN apps a ON a.id = h.current_app_id \
             WHERE h.slug = $1",
        )
        .bind(slug)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn create(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        group_id: GroupId,
    ) -> Result<App, ScriptRepositoryError> {
        let res = sqlx::query_as::<_, AppRow>(
            "INSERT INTO apps (slug, name, description, group_id) \
             VALUES ($1, $2, $3, $4) \
             RETURNING id, slug, name, description, group_id, created_at, updated_at",
        )
        .bind(slug)
        .bind(name)
        .bind(description)
        .bind(group_id.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(
                ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
            ),
            Err(e) => Err(e.into()),
        }
    }

    async fn create_with_takeover(
        &self,
        slug: &str,
        name: &str,
        description: Option<&str>,
        group_id: GroupId,
    ) -> Result<App, ScriptRepositoryError> {
        let mut tx = self.pool.begin().await?;
        sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
            .bind(slug)
            .execute(&mut *tx)
            .await?;
        let row = sqlx::query_as::<_, AppRow>(
            "INSERT INTO apps (slug, name, description, group_id) \
             VALUES ($1, $2, $3, $4) \
             RETURNING id, slug, name, description, group_id, created_at, updated_at",
        )
        .bind(slug)
        .bind(name)
        .bind(description)
        .bind(group_id.into_inner())
        .fetch_one(&mut *tx)
        .await;
        let row = match row {
            Ok(r) => r,
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
                return Err(ScriptRepositoryError::Conflict(format!(
                    "slug {slug:?} is already in use"
                )));
            }
            Err(e) => return Err(e.into()),
        };
        tx.commit().await?;
        Ok(row.into())
    }

    async fn update(
        &self,
        id: AppId,
        name: Option<&str>,
        description: Option<Option<&str>>,
    ) -> Result<App, ScriptRepositoryError> {
        let row = sqlx::query_as::<_, AppRow>(
            "UPDATE apps SET \
                name = COALESCE($2, name), \
                description = CASE WHEN $3::bool THEN $4 ELSE description END, \
                updated_at = NOW() \
             WHERE id = $1 \
             RETURNING id, slug, name, description, group_id, created_at, updated_at",
        )
        .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_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
    }

    async fn rename_slug(
        &self,
        id: AppId,
        new_slug: &str,
        take_over_history: bool,
    ) -> Result<App, ScriptRepositoryError> {
        let mut tx = self.pool.begin().await?;

        // 1. Read the current slug (so we can record it in history).
        let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
            .bind(id.into_inner())
            .fetch_optional(&mut *tx)
            .await?;
        let Some((current_slug,)) = current else {
            return Err(ScriptRepositoryError::Conflict(format!(
                "app {id} not found"
            )));
        };

        if current_slug == new_slug {
            // No-op rename; just return the row.
            let row = sqlx::query_as::<_, AppRow>(
                "SELECT id, slug, name, description, group_id, created_at, updated_at \
                 FROM apps WHERE id = $1",
            )
            .bind(id.into_inner())
            .fetch_one(&mut *tx)
            .await?;
            tx.commit().await?;
            return Ok(row.into());
        }

        // 2. If renaming back to this app's own retired slug, just
        //    consume the history row silently (no warning, no takeover
        //    flag required).
        let owns_history: Option<(uuid::Uuid,)> =
            sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
                .bind(new_slug)
                .fetch_optional(&mut *tx)
                .await?;

        match owns_history {
            Some((owner,)) if owner == id.into_inner() => {
                sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
                    .bind(new_slug)
                    .execute(&mut *tx)
                    .await?;
            }
            Some(_) if take_over_history => {
                sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
                    .bind(new_slug)
                    .execute(&mut *tx)
                    .await?;
            }
            Some(_) => {
                return Err(ScriptRepositoryError::Conflict(format!(
                    "slug {new_slug:?} is in history; rename with takeover to claim it"
                )));
            }
            None => {}
        }

        // 3. Record the current slug in history (replacing any older
        //    entry — the same slug can pass through history multiple
        //    times across many renames).
        sqlx::query(
            "INSERT INTO app_slug_history (slug, current_app_id) \
             VALUES ($1, $2) \
             ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
                                              retired_at = NOW()",
        )
        .bind(&current_slug)
        .bind(id.into_inner())
        .execute(&mut *tx)
        .await?;

        // 4. Apply the rename. Unique violation = another live app
        //    already holds this slug.
        let row = sqlx::query_as::<_, AppRow>(
            "UPDATE apps SET slug = $2, updated_at = NOW() \
             WHERE id = $1 \
             RETURNING id, slug, name, description, group_id, created_at, updated_at",
        )
        .bind(id.into_inner())
        .bind(new_slug)
        .fetch_one(&mut *tx)
        .await;
        let row = match row {
            Ok(r) => r,
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
                return Err(ScriptRepositoryError::Conflict(format!(
                    "slug {new_slug:?} is already in use by another app"
                )));
            }
            Err(e) => return Err(e.into()),
        };

        tx.commit().await?;
        Ok(row.into())
    }

    async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
        let res = sqlx::query("DELETE FROM apps WHERE id = $1")
            .bind(id.into_inner())
            .execute(&self.pool)
            .await;
        match res {
            Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
                "app {id} not found"
            ))),
            Ok(_) => Ok(()),
            Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
                // ON DELETE RESTRICT on scripts.app_id — surface a clean
                // "has dependents" error rather than a raw SQL message.
                Err(ScriptRepositoryError::Conflict(
                    "app still contains scripts; delete or move them first".into(),
                ))
            }
            Err(e) => Err(e.into()),
        }
    }

    async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
        let mut tx = self.pool.begin().await?;
        sqlx::query("DELETE FROM scripts WHERE app_id = $1")
            .bind(id.into_inner())
            .execute(&mut *tx)
            .await?;
        let res = sqlx::query("DELETE FROM apps WHERE id = $1")
            .bind(id.into_inner())
            .execute(&mut *tx)
            .await?;
        if res.rows_affected() == 0 {
            return Err(ScriptRepositoryError::Conflict(format!(
                "app {id} not found"
            )));
        }
        tx.commit().await?;
        Ok(())
    }

    async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
            .bind(id.into_inner())
            .fetch_one(&self.pool)
            .await?;
        Ok(count.0)
    }
}

#[derive(sqlx::FromRow)]
struct AppRow {
    id: uuid::Uuid,
    slug: String,
    name: String,
    description: Option<String>,
    group_id: uuid::Uuid,
    created_at: chrono::DateTime<chrono::Utc>,
    updated_at: chrono::DateTime<chrono::Utc>,
}

impl From<AppRow> for App {
    fn from(r: AppRow) -> Self {
        Self {
            id: r.id.into(),
            slug: r.slug,
            name: r.name,
            description: r.description,
            group_id: r.group_id.into(),
            created_at: r.created_at,
            updated_at: r.updated_at,
        }
    }
}
