use std::{
    collections::{HashMap, HashSet},
    fmt::Display,
};

use anyhow::anyhow;
use askama::Template;
use phono_backends::{
    client::WorkspaceClient,
    pg_acl::{PgAclItem, PgPrivilegeType},
    pg_class::PgClass,
    pg_role::RoleTree,
    rolnames::{
        ROLE_PREFIX_TABLE_OWNER, ROLE_PREFIX_TABLE_READER, ROLE_PREFIX_TABLE_WRITER,
        ROLE_PREFIX_USER,
    },
};
use phono_models::{accessors::Actor, service_cred::ServiceCred, user::User};
use phono_pestgros::escape_identifier;
use serde::{Deserialize, Serialize};
use sqlx::{postgres::types::Oid, prelude::FromRow, query, query_as};
use tracing::{Instrument, info_span};

use crate::errors::AppError;

#[derive(
    Clone,
    Copy,
    Debug,
    Deserialize,
    Eq,
    Hash,
    PartialEq,
    Serialize,
    strum::Display,
    strum::EnumIs,
    strum::EnumIter,
    strum::EnumString,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RelPermissionKind {
    Owner,
    Reader,
    Writer,
}

#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
#[template(path = "includes/rel_permission.html")]
pub(crate) struct RelPermission {
    pub(crate) kind: RelPermissionKind,
    pub(crate) rel_oid: Oid,
    pub(crate) relname: String,
}

impl RelPermission {
    /// Attempt to infer value from a role name in a specific workspace. If the
    /// role corresponds specifically to a relation and that relation is not
    /// present in the current workspace, or if the role does not align with a
    /// [`RelPermissionKind`], the returned value is `None`.
    pub async fn from_rolname(
        rolname: &str,
        client: &mut WorkspaceClient,
    ) -> sqlx::Result<Option<Self>> {
        let kind = if rolname.starts_with(ROLE_PREFIX_TABLE_OWNER) {
            RelPermissionKind::Owner
        } else if rolname.starts_with(ROLE_PREFIX_TABLE_READER) {
            RelPermissionKind::Reader
        } else if rolname.starts_with(ROLE_PREFIX_TABLE_WRITER) {
            RelPermissionKind::Writer
        } else {
            return Ok(None);
        };
        #[derive(FromRow)]
        struct RelInfo {
            oid: Oid,
            relname: String,
        }
        // TODO: Consider moving this to [`phono_backends`].
        let mut rels: Vec<RelInfo> = query_as(
            "
select oid, any_value(relname) as relname
from (
select
    oid,
    relname,
    (aclexplode(relacl)).grantee as grantee
    from pg_class
)
where grantee = $1::regrole::oid
group by oid
",
        )
        .bind(rolname)
        .fetch_all(client.get_conn())
        .await?;
        assert!(rels.len() <= 1);
        Ok(rels.pop().map(|rel| Self {
            kind,
            rel_oid: rel.oid,
            relname: rel.relname,
        }))
    }
}

// This is implemented so that `RelPermission` can be constructed inline in
// Askama templates via `(kind, relname, oid).into()`.
impl From<(RelPermissionKind, String, Oid)> for RelPermission {
    fn from((kind, relname, rel_oid): (RelPermissionKind, String, Oid)) -> Self {
        Self {
            kind,
            relname,
            rel_oid,
        }
    }
}

#[derive(Clone, Debug, strum::EnumIs)]
pub(crate) enum Grantee {
    User(User),
    ServiceCred(ServiceCred),
}

impl Display for Grantee {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::User(User { email, .. }) => write!(f, "{email}"),
            Self::ServiceCred(cred) => write!(f, "svc_{0}", cred.id.simple()),
        }
    }
}

#[derive(Debug, thiserror::Error)]
#[error("invalid variant")]
pub(crate) struct InvalidVariantError;

impl TryFrom<Grantee> for User {
    type Error = InvalidVariantError;

    fn try_from(value: Grantee) -> Result<Self, Self::Error> {
        if let Grantee::User(user) = value {
            Ok(user)
        } else {
            Err(InvalidVariantError)
        }
    }
}

/// Askama template which renders a collection of [`RelPermission`] badges and
/// an "Edit" button which opens a dialog with a form allowing permissions to be
/// assigned across multiple tables within a workspace.
#[derive(Clone, Debug, Template)]
#[template(path = "includes/permissions_editor.html")]
pub(crate) struct PermissionsEditor {
    /// User, invite, or service credential being granted permissions.
    pub(crate) target: Grantee,

    pub(crate) current_perms: HashSet<RelPermission>,

    /// Endpoint to use in the `<form action="">` attribute.
    pub(crate) update_endpoint: String,

    /// Set to true to show the "table owner" column or false to hide it.
    pub(crate) include_owner: bool,

    pub(crate) all_rels: Vec<PgClass>,

    /// Additional form fields to be rendered as `<input type="hidden">`.
    pub(crate) hidden_inputs: HashMap<String, String>,
}

/// Attempt to grant permissions on a relation. Permissions for which `actor`
/// does not hold the `ADMIN OPTION` in the backing database will be skipped
/// with a warning.
///
/// The role with which `workspace_client` is authenticated is important.
/// Postgres considers all granted privileges "dependent" on the admin
/// privileges of the role in use when granting them. Revoking the admin
/// privileges of the grantor will cascade if no other roles have granted the
/// same privileges independently to the same grantee. In practice, this means
/// that service credentials should be granted permissions via a client
/// authenticated as their owner, and other users should be granted permissions
/// via a client authenticated as the Phonograph root user. This behavior is
/// checked by debug assertions, unless `actor` is set to `Actor::Bypass`.
pub(crate) async fn grant_rel_permissions(
    target: Grantee,
    actor: Actor,
    perms: HashSet<RelPermission>,
    workspace_client: &mut WorkspaceClient,
) -> Result<(), AppError> {
    async {
        let target_rolname = match target.clone() {
            Grantee::ServiceCred(cred) => cred.rolname.clone(),
            Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
        };

        // Assert that function is called with `workspace_client` authentication
        // configured correctly. The check incurs an extra round trip to the backing
        // database, so it is skipped in production builds.
        #[cfg(debug_assertions)]
        {
            if let Actor::User(actor_id) = actor {
                let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
                #[derive(FromRow)]
                struct CurrentUserRow {
                    current_user: String,
                }
                let CurrentUserRow {
                    current_user: workspace_current_user,
                } = query_as("select current_user")
                    .fetch_one(workspace_client.get_conn())
                    .await?;
                match target {
                    Grantee::ServiceCred(_) => {
                        debug_assert!(workspace_current_user == actor_rolname)
                    }
                    Grantee::User(_) => debug_assert!(workspace_current_user != actor_rolname),
                }
            }
        }

        // `None` means that we're using `Actor::Bypass` and all roles are on the table.
        let permitted_rolnames: Option<HashSet<String>> = if let Actor::User(actor_id) = actor {
            let actor_rolname = format!("{ROLE_PREFIX_USER}{id}", id = actor_id.simple());
            Some(
                RoleTree::granted_to_rolname(&actor_rolname)
                    .fetch_tree(workspace_client)
                    .await?
                    .ok_or(anyhow!("failed to fetch role tree"))?
                    .flatten_inherited()
                    .into_iter()
                    .filter(|value| value.admin)
                    .map(|value| value.role.rolname)
                    .collect(),
            )
        } else {
            None
        };
        let target_rolname_esc = escape_identifier(&target_rolname);
        for perm in perms {
            let rel = PgClass::with_oid(perm.rel_oid)
                .fetch_one(workspace_client)
                .await?;
            let perm_rolname = match perm.kind {
                RelPermissionKind::Owner => get_owner_role(&rel),
                RelPermissionKind::Reader => get_reader_role(&rel),
                RelPermissionKind::Writer => get_writer_role(&rel),
            }?;
            if permitted_rolnames
                .as_ref()
                .is_none_or(|permitted_rolnames| {
                    permitted_rolnames.contains(perm_rolname)
                })
            {
                let perm_rolname_esc = escape_identifier(perm_rolname);
                query(&format!(
                    "grant {perm_rolname_esc} to {target_rolname_esc} with admin option"
                ))
                .execute(workspace_client.get_conn())
                .await?;
            } else {
                tracing::warn!("{actor} attempted to grant {perm_rolname} for which they do not have the admin option");
            }
        }
        Ok(())
    }
    .instrument(info_span!("grant_rel_permissions()"))
    .await
}

/// Attempt to revoke permissions on a relation.
///
/// Similarly to [`grant_rel_permissions`], the role with which
/// `workspace_client` is authenticated is important, in this case because
/// Postgres only allows privileges to be revoked by the role that granted them.
/// Revoking privileges using a client authenticated as a different role will
/// have no effect.
///
/// Note that if the same privilege has been granted multiple times by different
/// grantor roles, that privilege will remain in effect until all grants have
/// been undone. In practice, Phonograph should always be granting privileges to
/// user-specific Postgres roles via the Phonograph root role and to service
/// credential roles via the role associated with the credential's owner, so
/// redundant grants should be encountered rarely if ever.
pub(crate) async fn revoke_rel_permissions(
    target: Grantee,
    perms: HashSet<RelPermission>,
    workspace_client: &mut WorkspaceClient,
) -> Result<(), AppError> {
    let target_rolname = match target {
        Grantee::ServiceCred(cred) => cred.rolname.clone(),
        Grantee::User(User { id, .. }) => format!("{ROLE_PREFIX_USER}{id}", id = id.simple()),
    };
    for perm in perms {
        let rel = PgClass::with_oid(perm.rel_oid)
            .fetch_one(workspace_client)
            .await?;
        let target_rolname_esc = escape_identifier(&target_rolname);
        let perm_rolname = match perm.kind {
            RelPermissionKind::Owner => get_owner_role(&rel),
            RelPermissionKind::Reader => get_reader_role(&rel),
            RelPermissionKind::Writer => get_writer_role(&rel),
        }?;
        let perm_rolname_esc = escape_identifier(perm_rolname);
        query(&format!(
            "revoke {perm_rolname_esc} from {target_rolname_esc} cascade"
        ))
        .execute(workspace_client.get_conn())
        .await?;
    }
    Ok(())
}

/// Error type returned by functions which find certain Phonograph-generated
/// roles within relation ACLs.
#[derive(Clone, Copy, Debug, thiserror::Error)]
pub(crate) enum GetTableRoleError {
    #[error("relacl field not present on pg_class record")]
    MissingAcl,
    #[error("no role found with prefix {role_prefix}")]
    MissingRole { role_prefix: &'static str },
}

fn get_table_role<'a>(
    relacl: Option<&'a Vec<PgAclItem>>,
    required_privileges: HashSet<PgPrivilegeType>,
    disallowed_privileges: HashSet<PgPrivilegeType>,
    role_prefix: &'static str,
) -> Result<&'a str, GetTableRoleError> {
    relacl
        .ok_or(GetTableRoleError::MissingAcl)?
        .iter()
        .find_map(|acl_item| {
            if acl_item.grantee.starts_with(role_prefix) {
                let privileges_set: HashSet<PgPrivilegeType> = acl_item
                    .privileges
                    .iter()
                    .map(|privilege| privilege.privilege)
                    .collect();
                assert!(
                    privileges_set.intersection(&required_privileges).count()
                        == required_privileges.len()
                );
                assert!(privileges_set.intersection(&disallowed_privileges).count() == 0);
                Some(acl_item.grantee.as_str())
            } else {
                None
            }
        })
        .ok_or(GetTableRoleError::MissingRole { role_prefix })
}

/// Returns the name of the "table_owner" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_owner_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
    get_table_role(
        rel.relacl.as_ref(),
        [
            PgPrivilegeType::Insert,
            PgPrivilegeType::Update,
            PgPrivilegeType::Delete,
            PgPrivilegeType::Truncate,
        ]
        .into(),
        [].into(),
        ROLE_PREFIX_TABLE_OWNER,
    )
}

/// Returns the name of the "table_reader" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_reader_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
    get_table_role(
        rel.relacl.as_ref(),
        [PgPrivilegeType::Select].into(),
        [
            PgPrivilegeType::Insert,
            PgPrivilegeType::Update,
            PgPrivilegeType::Delete,
            PgPrivilegeType::Truncate,
        ]
        .into(),
        ROLE_PREFIX_TABLE_READER,
    )
}

/// Returns the name of the "table_writer" role created by Phonograph for a
/// particular workspace table. The role is assessed based on its name and the
/// table permissions directly granted to it. Returns an error if no matching
/// role is found, and panics if a role is found with excess permissions
/// granted to it directly.
pub(crate) fn get_writer_role(rel: &PgClass) -> Result<&str, GetTableRoleError> {
    get_table_role(
        rel.relacl.as_ref(),
        [PgPrivilegeType::Delete, PgPrivilegeType::Truncate].into(),
        [PgPrivilegeType::Select].into(),
        ROLE_PREFIX_TABLE_WRITER,
    )
}
