/*
 * This file is licensed under the Affero General Public License (AGPL) version 3.
 *
 * Copyright (C) 2026 Element Creations Ltd
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * See the GNU Affero General Public License for more details:
 * <https://www.gnu.org/licenses/agpl-3.0.html>.
 *
 */

//! Rust implementations of types from `synapse.types`.

use std::collections::HashSet;

use once_cell::sync::OnceCell;
use pyo3::prelude::*;
use pyo3::{
    exceptions::PyKeyError,
    types::{PyDict, PyDictMethods, PyList},
};

/// A reference to the `synapse.types.UserID` class.
static USER_ID_CLASS: OnceCell<Py<PyAny>> = OnceCell::new();

/// Access to the `synapse.types.UserID` class.
fn user_id_class(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
    Ok(USER_ID_CLASS
        .get_or_try_init(|| -> PyResult<_> {
            Ok(py.import("synapse.types")?.getattr("UserID")?.unbind())
        })?
        .bind(py))
}

/// Represents the user making a request.
#[pyclass(frozen, skip_from_py_object, get_all, eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requester {
    /// The ID of the user making the request, in string form (see
    /// [`Self::user`] for accessing the parsed `UserID`).
    pub user_id: String,
    /// The ID of the access token used for this request, or None for
    /// appservices, guests, and tokens generated by the admin API
    pub access_token_id: Option<i64>,
    /// True if the user making this request is a guest
    pub is_guest: bool,
    /// Any scopes associated with the access token used for this request, or an
    /// empty set if no token or a non-oauth token was used
    pub scope: HashSet<String>,
    /// True if the user making this request is shadow banned
    pub shadow_banned: bool,
    /// The device_id which was set at authentication time, or None for
    /// appservices, guests, and tokens generated by the admin API
    pub device_id: Option<String>,
    /// The ID of the AS requesting on behalf of the user, or None.
    pub app_service_id: Option<String>,
    /// The entity that authenticated when making the request.
    ///
    /// This is different to the `user_id` when an admin user or the server is
    /// "puppeting" the user.
    pub authenticated_entity: String,
}

#[pymethods]
impl Requester {
    #[new]
    #[pyo3(signature = (
        user,
        access_token_id,
        is_guest,
        scope,
        shadow_banned,
        device_id,
        app_service_id,
        authenticated_entity,
    ))]
    #[allow(clippy::too_many_arguments)]
    fn new(
        user: &Bound<'_, PyAny>,
        access_token_id: Option<i64>,
        is_guest: &Bound<'_, PyAny>,
        scope: HashSet<String>,
        shadow_banned: &Bound<'_, PyAny>,
        device_id: Option<String>,
        app_service_id: Option<String>,
        authenticated_entity: String,
    ) -> PyResult<Self> {
        // The `user` argument should be a `UserID`, which has a `to_string` for
        // getting the string form.
        let user_id = user.call_method0("to_string")?.extract::<String>()?;

        // The `is_guest` and `shadow_banned` arguments are expected to be
        // Python bools, but unfortunately Synapse often passes them as truthy
        // values (mainly due to reading from SQLite, which returns 0/1 for
        // bools).
        let is_guest = is_guest.is_truthy()?;
        let shadow_banned = shadow_banned.is_truthy()?;

        Ok(Requester {
            user_id,
            access_token_id,
            is_guest,
            scope,
            shadow_banned,
            device_id,
            app_service_id,
            authenticated_entity,
        })
    }

    /// The user making the request, as a Python `UserID`.
    #[getter]
    fn user<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        user_id_class(py)?.call_method1("from_string", (&self.user_id,))
    }

    /// Converts self to a type that can be serialized as JSON, and then
    /// deserialized by [`Self::deserialize`]
    fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
        let dict = PyDict::new(py);
        dict.set_item("user_id", &self.user_id)?;
        dict.set_item("access_token_id", self.access_token_id)?;
        dict.set_item("is_guest", self.is_guest)?;
        dict.set_item("scope", PyList::new(py, &self.scope)?)?;
        dict.set_item("shadow_banned", self.shadow_banned)?;
        dict.set_item("device_id", self.device_id.as_deref())?;
        // NB: the wire key is "app_server_id" (server, not service). Changing
        // this is non-trivial as it would break replication during a rolling
        // upgrade.
        dict.set_item("app_server_id", self.app_service_id.as_deref())?;
        dict.set_item("authenticated_entity", &self.authenticated_entity)?;
        Ok(dict)
    }

    /// Converts a dict that was produced by [`Self::serialize`] back into a
    /// [`Requester`].
    #[staticmethod]
    fn deserialize(py: Python<'_>, input: &Bound<'_, PyAny>) -> PyResult<Self> {
        let user_id = input.get_item("user_id")?.extract::<String>()?;
        let access_token_id = input
            .get_item("access_token_id")?
            .extract::<Option<i64>>()?;
        let is_guest = input.get_item("is_guest")?.is_truthy()?;

        // `serialize` stores the scope as a list, so extract it as a `Vec`
        // (which accepts any sequence) and collect into a set. For backwards
        // compatibility, "scope" is optional and defaults to an empty set if
        // not present.
        let scope = match input.get_item("scope") {
            Ok(scope) => scope.extract::<Vec<String>>()?.into_iter().collect(),
            Err(err) if err.is_instance_of::<PyKeyError>(py) => HashSet::new(),
            Err(err) => return Err(err),
        };

        let shadow_banned = input.get_item("shadow_banned")?.is_truthy()?;
        let device_id = input.get_item("device_id")?.extract::<Option<String>>()?;

        // The wire key is "app_server_id", not "app_service_id" — see `serialize`.
        let app_service_id = input
            .get_item("app_server_id")?
            .extract::<Option<String>>()?;

        let authenticated_entity = input
            .get_item("authenticated_entity")?
            .extract::<String>()?;

        Ok(Requester {
            user_id,
            access_token_id,
            is_guest,
            scope,
            shadow_banned,
            device_id,
            app_service_id,
            authenticated_entity,
        })
    }

    fn __repr__(&self) -> String {
        format!(
            "Requester(user_id={}, access_token_id={:?}, is_guest={}, scope={:?}, \
             shadow_banned={}, device_id={:?}, app_service_id={:?}, authenticated_entity={})",
            self.user_id,
            self.access_token_id,
            self.is_guest,
            self.scope,
            self.shadow_banned,
            self.device_id,
            self.app_service_id,
            self.authenticated_entity,
        )
    }
}

/// Called when registering modules with python.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
    let child_module = PyModule::new(py, "types")?;
    child_module.add_class::<Requester>()?;

    m.add_submodule(&child_module)?;

    // We need to manually add the module to sys.modules to make `from
    // synapse.synapse_rust.types import Requester` work.
    py.import("sys")?
        .getattr("modules")?
        .set_item("synapse.synapse_rust.types", child_module)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A `Requester` with every field populated, for use as a test fixture.
    fn sample_requester() -> Requester {
        Requester {
            user_id: "@alice:example.com".to_string(),
            access_token_id: Some(42),
            is_guest: false,
            scope: HashSet::from(["urn:matrix:client:api:*".to_string()]),
            shadow_banned: false,
            device_id: Some("ABCDEFG".to_string()),
            app_service_id: None,
            authenticated_entity: "@alice:example.com".to_string(),
        }
    }

    #[test]
    fn test_serialize() {
        Python::initialize();
        Python::attach(|py| -> Result<(), PyErr> {
            let requester = sample_requester();
            let dict = requester.serialize(py)?;
            let dict = dict.as_any();

            assert_eq!(
                dict.get_item("user_id")?.extract::<String>()?,
                "@alice:example.com"
            );
            assert_eq!(dict.get_item("access_token_id")?.extract::<i64>()?, 42);
            assert!(!dict.get_item("is_guest")?.extract::<bool>()?);
            assert_eq!(
                dict.get_item("scope")?.extract::<Vec<String>>()?,
                vec!["urn:matrix:client:api:*".to_string()]
            );
            assert!(!dict.get_item("shadow_banned")?.extract::<bool>()?);
            assert_eq!(
                dict.get_item("device_id")?.extract::<Option<String>>()?,
                Some("ABCDEFG".to_string())
            );
            assert_eq!(
                dict.get_item("authenticated_entity")?.extract::<String>()?,
                "@alice:example.com"
            );

            // The `app_service_id` field is serialized under the wire key
            // "app_server_id" (server, not service), and there must be no
            // "app_service_id" key.
            assert!(dict.get_item("app_server_id")?.is_none());
            assert!(!dict.contains("app_service_id")?);

            Ok(())
        })
        .unwrap();
    }

    #[test]
    fn test_serialize_deserialize_round_trip() {
        Python::initialize();
        Python::attach(|py| {
            // Use a requester that exercises the optional fields and the
            // `app_service_id` -> "app_server_id" wire-key mapping.
            let requester = Requester {
                user_id: "@bob:example.com".to_string(),
                access_token_id: None,
                is_guest: true,
                scope: HashSet::from(["a".to_string(), "b".to_string()]),
                shadow_banned: true,
                device_id: None,
                app_service_id: Some("my_appservice".to_string()),
                authenticated_entity: "@admin:example.com".to_string(),
            };

            let dict = requester.serialize(py).unwrap();
            let deserialized = Requester::deserialize(py, dict.as_any()).unwrap();
            let deserialized = Bound::new(py, deserialized).unwrap();

            assert_eq!(&requester, deserialized.get());
        });
    }

    #[test]
    fn test_deserialize_defaults_scope_when_missing() {
        Python::initialize();
        Python::attach(|py| {
            // An older serialized form may omit "scope"; it should default to
            // an empty set rather than erroring.
            let dict = PyDict::new(py);
            dict.set_item("user_id", "@alice:example.com").unwrap();
            dict.set_item("access_token_id", py.None()).unwrap();
            dict.set_item("is_guest", false).unwrap();
            dict.set_item("shadow_banned", false).unwrap();
            dict.set_item("device_id", py.None()).unwrap();
            dict.set_item("app_server_id", py.None()).unwrap();
            dict.set_item("authenticated_entity", "@alice:example.com")
                .unwrap();

            let requester = Requester::deserialize(py, dict.as_any()).unwrap();
            assert!(requester.scope.is_empty());
        });
    }

    #[test]
    fn test_deserialize_coerces_truthy_bools() {
        Python::initialize();
        Python::attach(|py| {
            // SQLite returns 0/1 for booleans, so non-bool truthy values must
            // be coerced for `is_guest` and `shadow_banned`.
            let dict = PyDict::new(py);
            dict.set_item("user_id", "@alice:example.com").unwrap();
            dict.set_item("access_token_id", py.None()).unwrap();
            dict.set_item("is_guest", 1).unwrap();
            dict.set_item("scope", PyList::empty(py)).unwrap();
            dict.set_item("shadow_banned", 0).unwrap();
            dict.set_item("device_id", py.None()).unwrap();
            dict.set_item("app_server_id", py.None()).unwrap();
            dict.set_item("authenticated_entity", "@alice:example.com")
                .unwrap();

            let requester = Requester::deserialize(py, dict.as_any()).unwrap();
            assert!(requester.is_guest);
            assert!(!requester.shadow_banned);
        });
    }
}
