use std::collections::HashSet;

use derive_builder::Builder;
use phono_backends::{
    client::WorkspaceClient, pg_acl::PgPrivilegeType, pg_class::PgClass, pg_database::PgDatabase,
    pg_role::RoleTree, rolnames::ROLE_PREFIX_USER,
};
use sqlx::postgres::types::Oid;
use tracing::{Instrument, debug, info_span};
use uuid::Uuid;

use crate::{client::AppDbClient, portal::Portal};

use super::{AccessError, Accessor, Actor};

/// Utility for fetching a [`Portal`], with authorization.
#[derive(Builder, Debug)]
#[builder(
    // Build fn should only be called internally, via `fetch_optional()`.
    build_fn(private, error = "super::AccessError"),
    // Callers interact primarily with the generated builder struct.
    name = "PortalAccessor",
    vis = "pub",
    // "Owned" pattern circumvents the `Clone` trait bound on fields.
    pattern = "owned",
)]
struct GeneratedPortalAccessor<'a> {
    /// Required. ID of the portal to be accessed.
    id: Uuid,

    /// Required. Identity against which to evaluate authorization checks.
    as_actor: Actor,

    /// Required. Client for the backing database.
    using_workspace_client: &'a mut WorkspaceClient,

    /// Required. Client for the application database.
    using_app_db: &'a mut AppDbClient,

    /// Optionally verify that the portal is associated with a specific
    /// workspace ID.
    #[builder(default, setter(strip_option))]
    verify_workspace_id: Option<Uuid>,

    /// Optionally verify that the portal is associated with a specific relation
    /// OID.
    #[builder(default, setter(strip_option))]
    verify_rel_oid: Option<Oid>,

    /// Optionally verify that the actor has or inherits specific Postgres
    /// permissions on the relation in the backing database.
    #[builder(default, setter(into))]
    // Using [`HashSet<PgPrivilegeType>`] with `#[builder(setter(into))]` is
    // more straightforward for callers than asking them to provide a
    // `&'a [PgPrivilege]`. Forcing a conversion to a heap-based data structure
    // feels wrong when callers will often be starting with a statically
    // allocated array of `PgPrivilegeType`s, but that optimization can be made
    // later if the trade-off with ergonomics proves warranted.
    verify_rel_permissions: HashSet<PgPrivilegeType>,

    // Find doc comment on setter method.
    #[builder(default, setter(custom))]
    verify_rel_ownership: bool,

    /// Optionally for performance, relation metadata may be provided by the
    /// caller so that the accessor doesn't need to refetch it behind the
    /// scenes.
    #[builder(default, setter(strip_option))]
    using_rel: Option<&'a PgClass>,
}

impl PortalAccessor<'_> {
    /// Optionally verify that the actor has or belongs to the role that owns
    /// the relation in the backing database.
    pub fn verify_rel_ownership(self) -> Self {
        Self {
            verify_rel_ownership: Some(true),
            ..self
        }
    }
}

impl<'a> Accessor<Portal> for PortalAccessor<'a> {
    async fn fetch_one(self) -> Result<Portal, AccessError> {
        let spec = self.build()?;
        async {
            debug!("accessing portal");
            let portal = Portal::with_id(spec.id)
                .fetch_optional(spec.using_app_db)
                .await?
                .ok_or_else(|| {
                    debug!("portal not found");
                    AccessError::NotFound
                })?;

            if spec
                .verify_workspace_id
                .is_some_and(|value| portal.workspace_id != value)
            {
                debug!("workspace_id check failed for portal");
                return Err(AccessError::NotFound);
            }
            if spec
                .verify_rel_oid
                .is_some_and(|value| portal.class_oid != value)
            {
                debug!("rel_oid check failed for portal");
                return Err(AccessError::NotFound);
            }

            let rel = if let Some(value) = spec.using_rel {
                value
            } else {
                &PgClass::with_oid(portal.class_oid)
                    .fetch_optional(spec.using_workspace_client)
                    .await?
                    .ok_or_else(|| {
                        debug!("unable to fetch PgClass for portal");
                        AccessError::NotFound
                    })?
            };

            let actor_rolname = match spec.as_actor {
                Actor::Bypass => None,
                Actor::User(user_id) => Some(format!(
                    "{ROLE_PREFIX_USER}{user_id}",
                    user_id = user_id.simple()
                )),
            };

            if let Some(actor_rolname) = actor_rolname {
                // Verify database CONNECT permissions.
                let pg_db = PgDatabase::current()
                    .fetch_one(spec.using_workspace_client)
                    .await?;
                if !pg_db.datacl.unwrap_or_default().iter().any(|acl| {
                    // Currently database connect permissions are always granted
                    // directly, though this may change in the future.
                    // TODO: Generalize to inherited roles
                    acl.grantee == actor_rolname
                        && acl
                            .privileges
                            .iter()
                            .any(|privilege| privilege.privilege == PgPrivilegeType::Connect)
                }) {
                    debug!("actor lacks postgres connect privileges");
                    return Err(AccessError::NotFound);
                }

                // Verify ACL permissions.
                //
                // No need to explicitly check whether
                // `verify_rel_permissions` is empty: database queries are
                // only performed based on roles which intersect with the
                // permissions check.
                //
                // This could alternatively be implemented using streams,
                // but this naive for-loop version is much more readable and
                // still bottlenecks on the same serial database access.
                let mut actor_permissions: HashSet<PgPrivilegeType> = HashSet::new();
                for acl_item in rel
                    .relacl
                    .as_ref()
                    .unwrap_or(&vec![])
                    .iter()
                    .filter(|acl_item| {
                        acl_item
                            .privileges
                            .iter()
                            .any(|value| spec.verify_rel_permissions.contains(&value.privilege))
                    })
                {
                    // The naive equality check is technically redundant,
                    // but it allows us to eliminate a database round trip
                    // if it evaluates to true.
                    if acl_item.grantee == actor_rolname
                        || RoleTree::members_of_rolname(&acl_item.grantee)
                            .fetch_tree(spec.using_workspace_client)
                            .await?
                            .map(|tree| tree.flatten_inherited())
                            .unwrap_or_default()
                            .iter()
                            .any(|value| value.role.rolname == actor_rolname)
                    {
                        for permission in acl_item.privileges.iter() {
                            actor_permissions.insert(permission.privilege);
                        }
                    }
                }
                if !actor_permissions.is_superset(&spec.verify_rel_permissions) {
                    debug!("actor lacks postgres privileges");
                    return Err(AccessError::NotFound);
                }

                // Verify relation ownership.
                if spec.verify_rel_ownership
                    // The naive equality check is technically redundant,
                    // but it allows us to eliminate a database round trip
                    // if it evaluates to true.
                    && (rel.regowner == actor_rolname
                        || !RoleTree::members_of_oid(rel.relowner)
                            .fetch_tree(spec.using_workspace_client)
                            .await?
                            .map(|tree| tree.flatten_inherited())
                            .unwrap_or_default()
                            .iter()
                            .any(|value| value.role.rolname == actor_rolname))
                {
                    debug!("actor is not relation owner");
                    return Err(AccessError::NotFound);
                }
            }

            Ok(portal)
        }
        .instrument(info_span!(
            "PortalAccessor::fetch_one()",
            portal_id = spec.id.to_string(),
            actor = spec.as_actor.to_string(),
        ))
        .await
    }
}
