<?php /*

 Composr
 Copyright (c) ocProducts, 2004-2016

 See text/EN/licence.txt for full licencing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  ocProducts Ltd
 * @package    core_notifications
 */

/*
Background details, to set the context and how we have structured things for consistency...

Notifications are one-off messages sent out in response to something happening on the site. They may get delivered via e-mail, etc.
Notifications may optionally create a message that staff might discuss, in which case a discussion link will be auto-appended to anyone having access to the admin_messaging module. This should be used sparingly - remember that any staff may raise such a notification by reporting some content, so it should only be particularly eventful stuff that spawns this.
People may get an RSS feed of notifications if they enable notifications via PT, as PTs have an RSS feed - that may then be connected to Growl, IM, or whatever service they may enjoy using (kind of quirky, but some power users enjoy this for the cool factor). It's good that we support the standards, without too much complexity.

There is a separate Composr action log, called via log_it. This is not related to the notifications system, although staff may choose a notification when anything is added to the action log.
Similarly, there is the Composr activities syndication system. This is not related either, but again notifications may be generated through this.
The Admin Zone front page shows tasks. These are not the same thing as notifications, although notifications may have been sent when they were set up (specifically there is a notification for when custom tasks have been added).

There are RSS feeds in Composr. These are completely unrelated to notifications, although can be used in a similar way (in that they'll change when the website content changes, so a polling RSS reader can detect new content).
Similarly, there is "realtime rain".
There is "what's new" and the newsletter, where again are separate.

Any notifications are CC'd to the configured CC email address (if there is one). This is like having that address get notifications for everything, even if they shouldn't normally be able to receive that notification (i.e. was targeted to a specific member(s)). But it's not really considered parts of the notifications system.
*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__notifications()
{
    if (!defined('A_FROM_SYSTEM_UNPRIVILEGED')) {
        // Notifications will be sent from one of the following if not a specific member ID
        define('A_FROM_SYSTEM_UNPRIVILEGED', -3); // Sent from system (website itself) *without* dangerous Comcode permission
        define('A_FROM_SYSTEM_PRIVILEGED', -2); // Sent from system (website itself) *with* dangerous Comcode permission

        // Notifications will be sent to one of the following if not to a specific list of member IDs
        define('A_TO_ANYONE_ENABLED', null);
    }

    global $NOTIFICATION_SETTING_CACHE;
    $NOTIFICATION_SETTING_CACHE = array();

    global $NOTIFICATION_LOCKDOWN_CACHE;
    $NOTIFICATION_LOCKDOWN_CACHE = array();

    global $NOTIFICATIONS_ON;
    $NOTIFICATIONS_ON = true;

    global $LAST_NOTIFICATION_LANG_CALL, $LAST_NOTIFICATION_TEMPLATE_CALL;
    $LAST_NOTIFICATION_LANG_CALL = null;
    $LAST_NOTIFICATION_TEMPLATE_CALL = null;

    global $ALL_NOTIFICATION_TYPES;
    $ALL_NOTIFICATION_TYPES = array(A_INSTANT_SMS, A_INSTANT_EMAIL, A_DAILY_EMAIL_DIGEST, A_WEEKLY_EMAIL_DIGEST, A_MONTHLY_EMAIL_DIGEST, A_INSTANT_PT, A_WEB_NOTIFICATION);

    global $HOOKS_NOTIFICATION_TYPES_EXTENDED;
    $HOOKS_NOTIFICATION_TYPES_EXTENDED = find_all_hooks('systems', 'notification_types_extended');

    foreach (array_keys($HOOKS_NOTIFICATION_TYPES_EXTENDED) as $hook) {
        require_code('hooks/systems/notification_types_extended/' . filter_naughty_harsh($hook));
        $ob = object_factory('Hook_notification_types_extended_' . filter_naughty_harsh($hook));
        $HOOKS_NOTIFICATION_TYPES_EXTENDED[$hook] = $ob;
        $ob->init();
    }
}

/**
 * Find the notification object for a particular notification code.
 *
 * @param  ID_TEXT $notification_code The notification code to use
 * @return ?object Notification object (null: could not find)
 * @ignore
 */
function _get_notification_ob_for_code($notification_code)
{
    $path = 'hooks/systems/notifications/' . filter_naughty(preg_replace('#\_\_\w*$#', '', $notification_code));
    if ((!is_file(get_file_base() . '/sources/' . $path . '.php')) && (!is_file(get_file_base() . '/sources_custom/' . $path . '.php'))) {
        require_all_lang();
        $hooks = find_all_hooks('systems', 'notifications');
        foreach (array_keys($hooks) as $hook) {
            $path = 'hooks/systems/notifications/' . filter_naughty_harsh($hook);
            require_code($path);
            $ob = object_factory('Hook_notification_' . filter_naughty_harsh($hook));
            if (method_exists($ob, 'list_handled_codes')) {
                if (array_key_exists($notification_code, $ob->list_handled_codes())) {
                    return $ob;
                }
            }
        }
    } else { // Ah, we know already (file exists directly) - so quick route
        require_code($path);
        return object_factory('Hook_notification_' . filter_naughty_harsh(preg_replace('#\_\_\w*$#', '', $notification_code)));
    }
    return null;
    //return object_factory('Hook_Notification'); // default
}

/**
 * Wraps do_lang, keeping a record of the last call. You can use when building the notification $message.
 * This allows notification handlers to possibly repeat the call with a customised language string.
 *
 * @param  ID_TEXT $codename The language string ID
 * @param  ?mixed $parameter1 The first parameter [string or Tempcode] (replaces {1}) (null: none)
 * @param  ?mixed $parameter2 The second parameter [string or Tempcode] (replaces {2}) (null: none)
 * @param  ?mixed $parameter3 The third parameter (replaces {3}). May be an array of [of string or Tempcode], to allow any number of additional args (null: none)
 * @param  ?LANGUAGE_NAME $lang The language to use (null: user's language)
 * @param  boolean $require_result Whether to cause Composr to exit if the lookup does not succeed
 * @return ?mixed The human-readable content (null: not found). String normally. Tempcode if Tempcode parameters.
 */
function do_notification_lang($codename, $parameter1 = null, $parameter2 = null, $parameter3 = null, $lang = null, $require_result = true)
{
    global $LAST_NOTIFICATION_LANG_CALL;
    $LAST_NOTIFICATION_LANG_CALL = array($codename, $parameter1, $parameter2, $parameter3, $lang, $require_result);

    if (strpos($codename, ':') !== false) {
        $codename = preg_replace('#^.*:#', '', $codename);
    }

    return do_lang($codename, $parameter1, $parameter2, $parameter3, $lang, $require_result);
}

/**
 * Wraps do_template, keeping a record of the last call. You can use when building the notification $message.
 * This allows notification handlers to possibly repeat the call with a customised template.
 *
 * @param  ID_TEXT $codename The codename of the template being loaded
 * @param  ?array $parameters A map of parameters for the template (key to value); you can have any number of parameters of any name, there is no set standard; having a _GUID parameter of random value is a convention (null: no parameters)
 * @param  ?LANGUAGE_NAME $lang The language to load the template in (templates can embed language references) (null: users own language)
 * @param  boolean $light_error Whether to not produce a stack dump if the template is missing
 * @param  ?ID_TEXT $fallback Alternate template to use if the primary one does not exist (null: none)
 * @param  string $suffix File type suffix of template file (e.g. .tpl)
 * @set    .tpl .js .xml .txt .css
 * @param  string $directory Subdirectory type to look in
 * @set    templates javascript xml text css
 * @param  ?ID_TEXT $theme Theme to use (null: current theme)
 * @return Tempcode The Tempcode for this template
 */
function do_notification_template($codename, $parameters = null, $lang = null, $light_error = false, $fallback = null, $suffix = '.tpl', $directory = 'templates', $theme = null)
{
    global $LAST_NOTIFICATION_TEMPLATE_CALL;
    $LAST_NOTIFICATION_TEMPLATE_CALL = array($codename, $parameters, $lang, $light_error, $fallback, $suffix, $directory, $theme);

    if ($light_error || !is_null($fallback)) {
        fatal_exit(do_lang_tempcode('INTERNAL_ERROR')); // We can't support these parameters
    }

    return do_template($codename, $parameters, $lang, $light_error, $fallback, $suffix, $directory, $theme);
}

/**
 * Send out a notification to members enabled.
 *
 * @param  ID_TEXT $notification_code The notification code to use
 * @param  ?SHORT_TEXT $code_category The category within the notification code (null: none)
 * @param  SHORT_TEXT $subject Message subject (in Comcode)
 * @param  LONG_TEXT $message Message body (in Comcode)
 * @param  ?array $to_member_ids List of enabled members to limit sending to (null: everyone)
 * @param  ?integer $from_member_id The member ID doing the sending. Either a MEMBER or a negative number (e.g. A_FROM_SYSTEM_UNPRIVILEGED) (null: current member)
 * @param  integer $priority The message priority (1=urgent, 3=normal, 5=low)
 * @range  1 5
 * @param  boolean $store_in_staff_messaging_system Whether to create a topic for discussion (ignored if the staff_messaging addon not installed)
 * @param  boolean $no_cc Whether to NOT CC to the CC address
 * @param  ?ID_TEXT $no_notify_for__notification_code DO NOT send notifications to: The notification code (null: no restriction)
 * @param  ?SHORT_TEXT $no_notify_for__code_category DO NOT send notifications to: The category within the notification code (null: none / no restriction)
 * @param  string $subject_prefix Only relevant if $store_in_staff_messaging_system is true: subject prefix for storage
 * @param  string $subject_suffix Only relevant if $store_in_staff_messaging_system is true: subject suffix for storage
 * @param  string $body_prefix Only relevant if $store_in_staff_messaging_system is true: body prefix for storage
 * @param  string $body_suffix Only relevant if $store_in_staff_messaging_system is true: body suffix for storage
 * @param  ?array $attachments A list of attachments (each attachment being a map, path=>filename) (null: none)
 * @param  boolean $use_real_from Whether we will make a "reply to" direct -- we only do this if we're allowed to disclose email addresses for this particular notification type (i.e. if it's a direct contact)
 */
function dispatch_notification($notification_code, $code_category, $subject, $message, $to_member_ids = null, $from_member_id = null, $priority = 3, $store_in_staff_messaging_system = false, $no_cc = false, $no_notify_for__notification_code = null, $no_notify_for__code_category = null, $subject_prefix = '', $subject_suffix = '', $body_prefix = '', $body_suffix = '', $attachments = null, $use_real_from = false)
{
    global $NOTIFICATIONS_ON;
    if (!$NOTIFICATIONS_ON) {
        require_code('files2');
        clean_temporary_mail_attachments($attachments);

        return;
    }

    if (!isset($GLOBALS['FORUM_DRIVER'])) {
        require_code('files2');
        clean_temporary_mail_attachments($attachments);

        return; // We're not in a position to send a notification
    }
    if ((function_exists('get_member')) && ($GLOBALS['FORUM_DRIVER']->is_super_admin(get_member())) && (get_param_integer('keep_no_notifications', 0) == 1)) {
        require_code('files2');
        clean_temporary_mail_attachments($attachments);

        return;
    }

    if ($subject == '') {
        $subject = '<' . $notification_code . ' -- ' . (is_null($code_category) ? '' : $code_category) . '>';
    }

    require_lang('notifications');

    if (running_script('install')) {
        require_code('files2');
        clean_temporary_mail_attachments($attachments);

        return;
    }

    $dispatcher = new Notification_dispatcher($notification_code, $code_category, $subject, $message, $to_member_ids, $from_member_id, $priority, $store_in_staff_messaging_system, $no_cc, $no_notify_for__notification_code, $no_notify_for__code_category, $subject_prefix, $subject_suffix, $body_prefix, $body_suffix, $attachments, $use_real_from);

    if ((get_param_integer('keep_debug_notifications', 0) == 1) || ($notification_code == 'task_completed') || (running_script('cron_bridge'))) {
        $dispatcher->dispatch();
    } else {
        require_code('tasks');
        global $CSSS;
        call_user_func_array__long_task(do_lang('_SEND_NOTIFICATION'), get_screen_title('_SEND_NOTIFICATION', true, null, null, null, false), 'dispatch_notification', array($dispatcher, array_keys($CSSS)), true, false, false);
    }

    global $LAST_NOTIFICATION_LANG_CALL;
    $LAST_NOTIFICATION_LANG_CALL = null;
}

/**
 * Dispatcher object.
 * Used to create a closure for a notification dispatch, so we can then tell that to send in the background (register_shutdown_function), for performance reasons.
 *
 * @package    core_notifications
 */
class Notification_dispatcher
{
    public $notification_code = null;
    public $code_category = null;
    public $subject = null;
    public $message = null;
    public $to_member_ids = null;
    public $from_member_id = null;
    public $priority = null;
    public $store_in_staff_messaging_system = null;
    public $no_cc = null;
    public $no_notify_for__notification_code = null;
    public $no_notify_for__code_category = null;
    public $subject_prefix = '';
    public $subject_suffix = '';
    public $body_prefix = '';
    public $body_suffix = '';
    public $attachments = null;
    public $use_real_from = false;

    /**
     * Construct notification dispatcher.
     *
     * @param  ID_TEXT $notification_code The notification code to use
     * @param  ?SHORT_TEXT $code_category The category within the notification code (null: none). If it is to have $store_in_staff_messaging_system, it must have the format <type>_<id>
     * @param  SHORT_TEXT $subject Message subject (in Comcode)
     * @param  LONG_TEXT $message Message body (in Comcode)
     * @param  ?array $to_member_ids List of enabled members to limit sending to (null: everyone)
     * @param  ?integer $from_member_id The member ID doing the sending. Either a MEMBER or a negative number (e.g. A_FROM_SYSTEM_UNPRIVILEGED) (null: current member)
     * @param  integer $priority The message priority (1=urgent, 3=normal, 5=low)
     * @range  1 5
     * @param  boolean $store_in_staff_messaging_system Whether to create a topic for discussion (ignored if the staff_messaging addon not installed)
     * @param  boolean $no_cc Whether to NOT CC to the CC address
     * @param  ?ID_TEXT $no_notify_for__notification_code DO NOT send notifications to: The notification code (null: no restriction)
     * @param  ?SHORT_TEXT $no_notify_for__code_category DO NOT send notifications to: The category within the notification code (null: none / no restriction)
     * @param  string $subject_prefix Only relevant if $store_in_staff_messaging_system is true: subject prefix for storage
     * @param  string $subject_suffix Only relevant if $store_in_staff_messaging_system is true: subject suffix for storage
     * @param  string $body_prefix Only relevant if $store_in_staff_messaging_system is true: body prefix for storage
     * @param  string $body_suffix Only relevant if $store_in_staff_messaging_system is true: body suffix for storage
     * @param  ?array $attachments A list of attachments (each attachment being a map, path=>filename) (null: none)
     * @param  boolean $use_real_from Whether we will make a "reply to" direct -- we only do this if we're allowed to disclose email addresses for this particular notification type (i.e. if it's a direct contact)
     */
    public function __construct($notification_code, $code_category, $subject, $message, $to_member_ids, $from_member_id, $priority, $store_in_staff_messaging_system, $no_cc, $no_notify_for__notification_code, $no_notify_for__code_category, $subject_prefix = '', $subject_suffix = '', $body_prefix = '', $body_suffix = '', $attachments = null, $use_real_from = false)
    {
        $this->notification_code = $notification_code;
        $this->code_category = $code_category;
        $this->subject = $subject;
        $this->message = $message;
        $this->to_member_ids = $to_member_ids;
        $this->from_member_id = is_null($from_member_id) ? get_member() : $from_member_id;
        $this->priority = $priority;
        $this->store_in_staff_messaging_system = $store_in_staff_messaging_system;
        $this->no_cc = $no_cc;
        $this->no_notify_for__notification_code = $no_notify_for__notification_code;
        $this->no_notify_for__code_category = $no_notify_for__code_category;
        $this->subject_prefix = $subject_prefix;
        $this->subject_suffix = $subject_suffix;
        $this->body_prefix = $body_prefix;
        $this->body_suffix = $body_suffix;
        $this->attachments = $attachments;
        $this->use_real_from = $use_real_from;
    }

    /**
     * Send out a notification to members enabled.
     */
    public function dispatch()
    {
        if (get_mass_import_mode()) {
            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);

            return;
        }

        cms_profile_start_for('Notification_dispatcher');

        $subject = $this->subject;
        $message = $this->message;
        $no_cc = $this->no_cc;

        if ($GLOBALS['DEV_MODE']) {
            if ((strpos($this->message, 'keep_devtest') !== false) && ($this->notification_code != 'messaging') && (strpos($this->notification_code, 'error_occurred') === false) && ($this->notification_code != 'hack_attack') && ($this->notification_code != 'auto_ban') && (strpos($this->message, running_script('index') ? static_evaluate_tempcode(build_url(array('page' => '_SELF'), '_SELF', null, true, false, true)) : get_self_url_easy()) === false) && ((strpos(cms_srv('HTTP_REFERER'), 'keep_devtest') === false) || (strpos($this->message, cms_srv('HTTP_REFERER')) === false))) {// Bad URL - it has to be general, not session-specific
                fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }
        }

        $ob = _get_notification_ob_for_code($this->notification_code);
        if (is_null($ob)) {
            if ((strpos($this->notification_code, '__') === false) && (get_page_name() != 'admin_setupwizard')) {// Setupwizard may have removed after register_shutdown_function was called
                fatal_exit('Missing notification code: ' . $this->notification_code);
            }

            require_code('files2');
            clean_temporary_mail_attachments($this->attachments);

            cms_profile_end_for('Notification_dispatcher', $subject);

            return;
        }

        require_lang('notifications');
        require_code('mail');

        if (($this->store_in_staff_messaging_system) && (addon_installed('staff_messaging'))) {
            require_lang('messaging');

            list($type, $id) = explode('_', $this->code_category, 2);
            $message_url = build_url(array('page' => 'admin_messaging', 'type' => 'view', 'id' => $id, 'message_type' => $type), get_module_zone('admin_messaging'), null, false, false, true);
            $message = do_lang('MESSAGING_NOTIFICATION_WRAPPER', $message, $message_url->evaluate());

            $post_title = $this->subject_prefix . post_param_string('title', '') . $this->subject_suffix;
            $post = $this->body_prefix . post_param_string('post', '') . $this->body_suffix;

            require_code('feedback');
            actualise_post_comment(true, $type, $id, $message_url, $subject, get_option('messaging_forum_name'), true, 1, true, true, true, $post_title, $post);
        }

        $testing = (get_param_integer('keep_debug_notifications', 0) == 1);

        $start = 0;
        $max = 300;
        do {
            list($members, $possibly_has_more) = $ob->list_members_who_have_enabled($this->notification_code, $this->code_category, $this->to_member_ids, $start, $max);

            if (get_value('notification_safety_testing') === '1') {
                if (count($members) > 20) {
                    $members = array(6 => A_INSTANT_EMAIL); // This is just for testing, if lots of notifications going out it's probably a scary bug, so send just to Chris (#6) with a note
                    $message = 'OVER-ADDRESSED?' . "\n\n" . $message;
                }
            }

            foreach ($members as $to_member_id => $setting) {
                if (!is_null($this->no_notify_for__notification_code)) {
                    if (notifications_enabled($this->no_notify_for__notification_code, $this->no_notify_for__code_category, $to_member_id)) {
                        continue; // Signal they are getting some other notification for this
                    }
                }

                if (($to_member_id !== $this->from_member_id) || ($testing)) {
                    $no_cc = _dispatch_notification_to_member($to_member_id, $setting, $this->notification_code, $this->code_category, $subject, $message, $this->from_member_id, $this->priority, $no_cc, $this->attachments, $this->use_real_from);
                }
            }

            $start += $max;
        } while ($possibly_has_more);

        require_code('files2');
        clean_temporary_mail_attachments($this->attachments);

        cms_profile_end_for('Notification_dispatcher', $subject);
    }
}

/**
 * Find whether a particular kind of notification is available.
 *
 * @param  integer $setting The notification setting
 * @param  ?MEMBER $member_id Member to check for (null: just check globally)
 * @return boolean Whether it is available
 *
 * @ignore
 */
function _notification_setting_available($setting, $member_id = null)
{
    static $nsa_cache = array();
    if (isset($nsa_cache[$setting][$member_id])) {
        return $nsa_cache[$setting][$member_id];
    }

    $system_wide = false;
    $for_member = false;

    switch ($setting) {
        case A_WEB_NOTIFICATION:
            if (get_option('web_notifications_enabled') == '1') {
                $system_wide = true;
                $for_member = true;
            }
            break;

        case A_INSTANT_EMAIL:
            $system_wide = true;
            if ($system_wide && !is_null($member_id)) {
                $for_member = ($GLOBALS['FORUM_DRIVER']->get_member_email_address($member_id) != '');
            }
            break;

        case A_DAILY_EMAIL_DIGEST:
        case A_WEEKLY_EMAIL_DIGEST:
        case A_MONTHLY_EMAIL_DIGEST:
            $system_wide = (cron_installed()) && (get_option('notification_enable_digests') == '1');
            if ($system_wide && !is_null($member_id)) {
                $for_member = ($GLOBALS['FORUM_DRIVER']->get_member_email_address($member_id) != '');
            }
            break;

        case A_INSTANT_SMS:
            $system_wide = (addon_installed('sms')) && (get_option('sms_api_id') != '');
            if ($system_wide && !is_null($member_id)) {
                require_code('permissions');
                if (has_privilege($member_id, 'use_sms')) {
                    require_code('sms');
                    $cpf_values = $GLOBALS['FORUM_DRIVER']->get_custom_fields($member_id);
                    if (!is_null($cpf_values)) {
                        if (array_key_exists('mobile_phone_number', $cpf_values)) {
                            $for_member = (cleanup_mobile_number($cpf_values['mobile_phone_number']) != '');
                        }
                    }
                }
            }
            break;

        case A_INSTANT_PT:
            $system_wide = (get_forum_type() == 'cns') && (addon_installed('cns_forum')) && (get_option('notification_enable_private_topics') == '1');
            if ($system_wide && !is_null($member_id)) {
                require_code('permissions');
                $for_member = has_privilege($member_id, 'use_pt');
            }
            break;

        default:
            global $HOOKS_NOTIFICATION_TYPES_EXTENDED;
            foreach ($HOOKS_NOTIFICATION_TYPES_EXTENDED as $hook => $ob) {
                $ob->_notification_setting_available($setting, $member_id, $system_wide, $for_member);
            }
            break;
    }

    $ret = $system_wide && (is_null($member_id) || $for_member);
    $nsa_cache[$setting][$member_id] = $ret;
    return $ret;
}

/**
 * Find what a member usually receives notifications on. Has some advanced searching support, and checks what is permissable.
 *
 * @param  MEMBER $to_member_id Member to send to
 * @param  ID_TEXT $notification_code The notification code to use
 * @return integer Normal setting
 *
 * @ignore
 */
function _find_member_statistical_notification_type($to_member_id, $notification_code)
{
    global $HOOKS_NOTIFICATION_TYPES_EXTENDED;

    // Pre-sweep incase a hook really really wants a particular notification code
    foreach ($HOOKS_NOTIFICATION_TYPES_EXTENDED as $hook => $ob) {
        if (method_exists($ob, '_find_member_statistical_notification_type')) {
            $setting = $ob->_find_member_statistical_notification_type($to_member_id, $notification_code, true);
            if (!is_null($setting)) {
                $setting |= A_WEB_NOTIFICATION;
                return $setting;
            }
        }
    }

    static $cache = array();
    if (isset($cache[$to_member_id])) {
        return $cache[$to_member_id];
    }

    $setting = mixed();

    $notifications_enabled = $GLOBALS['SITE_DB']->query_select('notifications_enabled', array('l_setting'), array('l_member_id' => $to_member_id, 'l_notification_code' => $notification_code), '', 100/*within reason*/);
    if (count($notifications_enabled) == 0) {
        $notifications_enabled = $GLOBALS['SITE_DB']->query_select('notifications_enabled', array('l_setting'), array('l_member_id' => $to_member_id, 'l_code_category' => ''), '', 100/*within reason*/);
    }

    // If no notifications so far, we look for defaults
    if (count($notifications_enabled) == 0) {
        foreach ($HOOKS_NOTIFICATION_TYPES_EXTENDED as $hook => $ob) {
            if (method_exists($ob, '_find_member_statistical_notification_type')) {
                $setting = $ob->_find_member_statistical_notification_type($to_member_id, $notification_code, false);
                if (!is_null($setting)) {
                    break;
                }
            }
        }

        if (is_null($setting)) {
            if (_notification_setting_available(A_INSTANT_EMAIL, $to_member_id)) { // Default to e-mail
                $setting = A_INSTANT_EMAIL;
            }
        }
    }

    // Search for what can be done to find true statistical result
    if (is_null($setting)) {
        $possible_settings = array();
        $best_settings = array();
        global $ALL_NOTIFICATION_TYPES;
        foreach ($ALL_NOTIFICATION_TYPES as $possible_setting) {
            if (_notification_setting_available($possible_setting, $to_member_id)) {
                $possible_settings[] = $possible_setting;
            }
        }
        foreach ($notifications_enabled as $ml) {
            if ($ml['l_setting'] >= 0) {
                // Compound setting as possibility
                if (($ml['l_setting'] & $possible_setting) != 0) {
                    if (!isset($best_settings[$ml['l_setting']])) {
                        $best_settings[$ml['l_setting']] = 0;
                    }
                    $best_settings[$ml['l_setting']]++;
                }

                // Individual settings as possibilities
                foreach ($possible_settings as $possible_setting) {
                    if (($ml['l_setting'] & $possible_setting) != 0) {
                        if (!isset($best_settings[$possible_setting])) {
                            $best_settings[$possible_setting] = 0;
                        }
                        $best_settings[$possible_setting]++;
                    }
                }
            }
        }
        krsort($best_settings); // So compound settings come first
        arsort($best_settings); // So best possibilities come first
        reset($best_settings);
        $setting = key($best_settings);
        if (is_null($setting)) {
            $setting = _notification_setting_available(A_INSTANT_EMAIL, $to_member_id) ? A_INSTANT_EMAIL : A_WEB_NOTIFICATION; // Nothing available, so save as an e-mail notification even though it cannot be received
        }
    }

    // Cache/return
    $cache[$to_member_id] = $setting;
    return $setting;
}

/**
 * Send out a notification to a member.
 *
 * @param  MEMBER $to_member_id Member to send to
 * @param  integer $setting Listening setting
 * @param  ID_TEXT $notification_code The notification code to use
 * @param  ?SHORT_TEXT $code_category The category within the notification code (null: none)
 * @param  SHORT_TEXT $subject Message subject (in Comcode)
 * @param  LONG_TEXT $message Message body (in Comcode)
 * @param  integer $from_member_id The member ID doing the sending. Either a MEMBER or a negative number (e.g. A_FROM_SYSTEM_UNPRIVILEGED)
 * @param  integer $priority The message priority (1=urgent, 3=normal, 5=low)
 * @range  1 5
 * @param  boolean $no_cc Whether to NOT CC to the CC address
 * @param  ?array $attachments A list of attachments (each attachment being a map, path=>filename) (null: none)
 * @param  boolean $use_real_from Whether we will make a "reply to" direct -- we only do this if we're allowed to disclose email addresses for this particular notification type (i.e. if it's a direct contact)
 * @return boolean New $no_cc setting
 *
 * @ignore
 */
function _dispatch_notification_to_member($to_member_id, $setting, $notification_code, $code_category, $subject, $message, $from_member_id, $priority, $no_cc, $attachments, $use_real_from)
{
    // Fish out some general details of the sender
    $to_name = $GLOBALS['FORUM_DRIVER']->get_username($to_member_id, true);
    if ($to_name === null) {
        return $no_cc;
    }
    $from_email = '';
    $from_name = '';
    $from_member_id_shown = db_get_first_id();
    if ((!is_null($from_member_id)) && ($from_member_id >= 0)) {
        if ($use_real_from) {
            $from_email = $GLOBALS['FORUM_DRIVER']->get_member_email_address($from_member_id);
            $from_name = $GLOBALS['FORUM_DRIVER']->get_username($from_member_id, true);
            if ($from_name === null) {
                $from_name = '';
            }
            $from_member_id_shown = $from_member_id;
        }
    } else {
        $use_real_from = false;
    }
    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_join_timestamp($to_member_id);

    $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

    // If none-specified, we'll need to be clever now
    if ($setting == A__STATISTICAL) {
        $setting = _find_member_statistical_notification_type($to_member_id, $notification_code);
    }

    // Banned members can't access the site, force to be an e-mail notification (which actually will only go through if it is an urgent priority notification)
    if (($GLOBALS['FORUM_DRIVER']->is_banned($to_member_id)) && ($setting != 0) && (_notification_setting_available(A_INSTANT_EMAIL, $to_member_id))) {
        $setting = A_INSTANT_EMAIL;
    }

    $needs_manual_cc = true;

    $message_to_send = $message; // May get tweaked, if we have some kind of error to explain, etc

    // Send according to the listen setting...

    if (_notification_setting_available(A_INSTANT_SMS, $to_member_id)) {
        if (($setting & A_INSTANT_SMS) != 0) {
            $wrapped_message = do_lang('NOTIFICATION_SMS_COMPLETE_WRAP', $subject, $message_to_send); // Language string ID may be modified to include {2}, but would cost more. Default just has {1}.

            require_code('sms');
            $successes = sms_wrap($wrapped_message, array($to_member_id));
            if ($successes == 0) { // Could not send
                $setting = $setting | A_INSTANT_EMAIL; // Make sure it also goes to email then
                $message_to_send = do_lang('sms:INSTEAD_OF_SMS', $message);
            }
        }
    }

    if (_notification_setting_available(A_INSTANT_EMAIL, $to_member_id)) {
        if (($setting & A_INSTANT_EMAIL) != 0) {
            $to_email = $GLOBALS['FORUM_DRIVER']->get_member_email_address($to_member_id);
            if ($to_email != '') {
                $wrapped_subject = do_lang('NOTIFICATION_EMAIL_SUBJECT_WRAP', $subject, comcode_escape(get_site_name()));
                $wrapped_message = do_lang($use_real_from ? 'NOTIFICATION_EMAIL_MESSAGE_WRAP_DIRECT_REPLY' : 'NOTIFICATION_EMAIL_MESSAGE_WRAP', $message_to_send, comcode_escape(get_site_name()));

                mail_wrap(
                    $wrapped_subject,
                    $wrapped_message,
                    array($to_email),
                    $to_name,
                    $from_email,
                    $from_name,
                    $priority,
                    $attachments,
                    $no_cc,
                    ($from_member_id < 0) ? $GLOBALS['FORUM_DRIVER']->get_guest_id() : $from_member_id,
                    ($from_member_id == A_FROM_SYSTEM_PRIVILEGED),
                    false,
                    false,
                    'MAIL',
                    running_script('tasks') || running_script('cron_bridge'),
                    null,
                    null,
                    $join_time
                );

                $needs_manual_cc = false;
                $no_cc = true; // Don't CC again
            }
        }
    }

    $frequencies = array(
        A_DAILY_EMAIL_DIGEST,
        A_WEEKLY_EMAIL_DIGEST,
        A_MONTHLY_EMAIL_DIGEST,
        A_WEB_NOTIFICATION,
    );
    foreach ($frequencies as $frequency) {
        if (!_notification_setting_available($frequency, $to_member_id)) {
            continue;
        }

        if (($setting & $frequency) != 0) {
            if ($frequency == A_WEB_NOTIFICATION) {
                if (get_option('pt_notifications_as_web') == '0') {
                    if (
                        ($notification_code == 'cns_new_pt') ||
                        ($notification_code == 'cns_topic' && is_numeric($code_category) && is_null($GLOBALS['FORUM_DB']->query_select_value_if_there('f_topics', 't_forum_id', array('id' => intval($code_category)))))
                    ) {
                        continue;
                    }
                }
                require_code('files');
                $path = get_custom_file_base() . '/data_custom/modules/web_notifications';
                cms_file_put_contents_safe($path . '/latest.bin', strval(time()), FILE_WRITE_FAILURE_SOFT | FILE_WRITE_FIX_PERMISSIONS);
            }

            inject_web_resources_context_to_comcode($message);

            $map = array(
                'd_subject' => $subject,
                'd_from_member_id' => $from_member_id,
                'd_to_member_id' => $to_member_id,
                'd_priority' => $priority,
                'd_no_cc' => $no_cc ? 1 : 0,
                'd_date_and_time' => time(),
                'd_notification_code' => substr($notification_code, 0, 80),
                'd_code_category' => is_null($code_category) ? '' : $code_category,
                'd_frequency' => $frequency,
                'd_read' => 0,
            );
            $map += insert_lang_comcode('d_message', $message, 4);
            $GLOBALS['SITE_DB']->query_insert('digestives_tin', $map);

            $GLOBALS['SITE_DB']->query_insert('digestives_consumed', array(
                'c_member_id' => $to_member_id,
                'c_frequency' => $frequency,
                'c_time' => time(),
            ), false, true/*If we've not set up first digest time, make it the digest period from now; if we have then silent error is suppressed*/);

            decache('_get_notifications', null, $to_member_id);
        }
    }

    $needs_manual_cc = false;

    if (_notification_setting_available(A_INSTANT_PT, $to_member_id)) {
        if (($setting & A_INSTANT_PT) != 0) {
            require_code('cns_topics_action');
            require_code('cns_posts_action');

            $wrapped_subject = do_lang('NOTIFICATION_PT_SUBJECT_WRAP', $subject);
            $wrapped_message = do_lang($use_real_from ? 'NOTIFICATION_PT_MESSAGE_WRAP_DIRECT_REPLY' : 'NOTIFICATION_PT_MESSAGE_WRAP', $message_to_send);

            // NB: These are posted by Guest (system) although the display name is set to the member triggering. This is intentional to stop said member getting unexpected replies.
            $topic_id = cns_make_topic(null, '', 'icons/14x14/cns_topic_modifiers/announcement', 1, 1, 0, 0, 0, $from_member_id_shown, $to_member_id, false, 0, null, '');
            cns_make_post($topic_id, $wrapped_subject, $wrapped_message, 0, true, 1, 0, ($from_member_id < 0) ? do_lang('SYSTEM') : $from_name, null, null, $from_member_id_shown, null, null, null, false, true, null, true, $wrapped_subject, 0, null, true, true, true, ($from_member_id == A_FROM_SYSTEM_PRIVILEGED));
        }
    }

    global $HOOKS_NOTIFICATION_TYPES_EXTENDED;
    foreach ($HOOKS_NOTIFICATION_TYPES_EXTENDED as $hook => $ob) {
        $ob->_dispatch_notification_to_member($to_member_id, $setting, $notification_code, $code_category, $subject, $message, $from_member_id, $priority, $no_cc, $attachments, $use_real_from);
    }

    // Send to staff CC address regardless
    if ((!$no_cc) && ($needs_manual_cc)) {
        $no_cc = true; // Don't CC again

        $to_email = get_option('cc_address');
        if ($to_email != '') {
            mail_wrap(
                $subject,
                $message,
                array($to_email),
                $to_name,
                $from_email,
                $from_name,
                $priority,
                null,
                true,
                ($from_member_id < 0) ? null : $from_member_id,
                ($from_member_id == A_FROM_SYSTEM_PRIVILEGED),
                false,
                false,
                'MAIL',
                false,
                null,
                null,
                $join_time
            );
        }
    }

    return $no_cc;
}

/**
 * Enable notifications for a member on a notification type+category.
 *
 * @param  ID_TEXT $notification_code The notification code to use
 * @param  ?SHORT_TEXT $notification_category The category within the notification code (null: none)
 * @param  ?MEMBER $member_id The member being signed up (null: current member)
 * @param  ?integer $setting Setting to use (null: default)
 * @param  boolean $reset_for_all_types Reset all notification types, not just set for $setting
 */
function enable_notifications($notification_code, $notification_category, $member_id = null, $setting = null, $reset_for_all_types = true)
{
    if (is_null($member_id)) {
        $member_id = get_member();
    }
    if (is_guest($member_id)) {
        return;
    }

    $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

    $map = array(
        'l_member_id' => $member_id,
        'l_notification_code' => substr($notification_code, 0, 80),
        'l_code_category' => is_null($notification_category) ? '' : $notification_category,
    );
    if (!$reset_for_all_types) {
        $map['l_setting'] = $setting;
    }

    if (is_null($setting)) {
        $ob = _get_notification_ob_for_code($notification_code);
        if (is_null($ob)) {
            warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
        }
        $setting = $ob->get_default_auto_setting($notification_code, $notification_category);
        if ($setting == A__STATISTICAL || !_notification_setting_available($setting, $member_id)) {
            $setting = _find_member_statistical_notification_type($member_id, $notification_code);
        }
    } elseif ($setting != A_NA) {
        // Check there is actually something to do here (when saving notifications tab usually everything will be still the same)
        $test = $db->query_select_value_if_there('notifications_enabled', 'l_setting', $map);
        if ($test === $setting) {
            return;
        }
    }

    $db->query_delete('notifications_enabled', $map);

    // Save new setting. Needs to do this even for A_NA, as otherwise Composr would call up the default upon a missing value
    $map['l_setting'] = $setting;
    $db->query_insert('notifications_enabled', $map);

    if (($notification_code == 'comment_posted') && (get_forum_type() == 'cns') && (!is_null($notification_category))) { // Sync comment_posted ones to also monitor the forum ones; no need for opposite way as comment ones already trigger forum ones
        $topic_id = $GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier(get_option('comments_forum_name'), $notification_category, do_lang('COMMENT'));
        if (!is_null($topic_id)) {
            enable_notifications('cns_topic', strval($topic_id), $member_id);
        }
    }

    global $NOTIFICATION_SETTING_CACHE;
    $NOTIFICATION_SETTING_CACHE = array();
}

/**
 * Disable notifications for a member on a notification type+category. Chances are you don't want to call this, you want to call enable_notifications with $setting = A_NA. That'll stop the default coming back.
 *
 * @param  ID_TEXT $notification_code The notification code to use
 * @param  SHORT_TEXT $notification_category The category within the notification code
 * @param  ?MEMBER $member_id The member being de-signed up (null: current member)
 */
function disable_notifications($notification_code, $notification_category, $member_id = null)
{
    if (is_null($member_id)) {
        $member_id = get_member();
    }
    if (is_guest($member_id)) {
        return;
    }

    $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

    $db->query_delete('notifications_enabled', array(
        'l_member_id' => $member_id,
        'l_notification_code' => substr($notification_code, 0, 80),
        'l_code_category' => $notification_category,
    ));

    if (($notification_code == 'comment_posted') && (get_forum_type() == 'cns')) { // Sync comment_posted ones to the forum ones
        $topic_id = $GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier(get_option('comments_forum_name'), $notification_category, do_lang('COMMENT'));
        if (!is_null($topic_id)) {
            disable_notifications('cns_topic', strval($topic_id), $member_id);
        }
    }

    global $NOTIFICATION_SETTING_CACHE;
    $NOTIFICATION_SETTING_CACHE = array();
}

/**
 * Find whether notifications are enabled for a member on a notification type+category. Does not check security (must go through notification object for that).
 *
 * @param  ID_TEXT $notification_code The notification code to check
 * @param  ?SHORT_TEXT $notification_category The category within the notification code (null: none)
 * @param  ?MEMBER $member_id The member being de-signed up (null: current member)
 * @return boolean Whether they are
 */
function notifications_enabled($notification_code, $notification_category, $member_id = null)
{
    return (notifications_setting($notification_code, $notification_category, $member_id) != A_NA);
}

/**
 * Find whether a notification is locked-down (i.e. cannot be set).
 *
 * @param  ID_TEXT $notification_code The notification code to check
 * @return ?BINARY Lock-down status (null: not locked down)
 */
function notification_locked_down($notification_code)
{
    global $NOTIFICATION_LOCKDOWN_CACHE;

    if (array_key_exists($notification_code, $NOTIFICATION_LOCKDOWN_CACHE)) {
        return $NOTIFICATION_LOCKDOWN_CACHE[$notification_code];
    }

    $test = $GLOBALS['SITE_DB']->query_select_value_if_there('notification_lockdown', 'l_setting', array(
        'l_notification_code' => substr($notification_code, 0, 80),
    ));

    $NOTIFICATION_LOCKDOWN_CACHE[$notification_code] = $test;

    return $test;
}

/**
 * Find how notifications are enabled for a member on a notification type+category. Does not check security (must go through notification object for that).
 *
 * @param  ID_TEXT $notification_code The notification code to check
 * @param  ?SHORT_TEXT $notification_category The category within the notification code (null: none)
 * @param  ?MEMBER $member_id The member being de-signed up (null: current member)
 * @return integer How they are
 */
function notifications_setting($notification_code, $notification_category, $member_id = null)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    $specific_where = array(
        'l_member_id' => $member_id,
        'l_notification_code' => substr($notification_code, 0, 80),
        'l_code_category' => ($notification_category === null) ? '' : $notification_category,
    );

    global $NOTIFICATION_SETTING_CACHE;
    if (isset($NOTIFICATION_SETTING_CACHE[serialize($specific_where)])) {
        return $NOTIFICATION_SETTING_CACHE[serialize($specific_where)];
    }

    $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

    $test = notification_locked_down($notification_code);
    if ($test === null) {
        $test = $db->query_select_value_if_there('notifications_enabled', 'l_setting', $specific_where);

        if (($test === null) && ($notification_category !== null)) {
            $test = $db->query_select_value_if_there('notifications_enabled', 'l_setting', array(
                'l_member_id' => $member_id,
                'l_notification_code' => substr($notification_code, 0, 80),
                'l_code_category' => '',
            ));
        }
        if ($test === null) {
            $ob = _get_notification_ob_for_code($notification_code);
            if ($ob === null) {
                return A_NA; // Can happen in template test sets, as this can be called up by a symbol
            }
            //if ($ob === null) fatal_exit(do_lang_tempcode('INTERNAL_ERROR'));
            $test = $ob->get_initial_setting($notification_code, $notification_category);
        }
    }

    $NOTIFICATION_SETTING_CACHE[serialize($specific_where)] = $test;
    return $test;
}

/**
 * Disable notifications for all members on a certain notification type+category.
 *
 * @param  ID_TEXT $notification_code The notification code
 * @param  ?SHORT_TEXT $notification_category The category within the notification code (null: none)
 */
function delete_all_notifications_on($notification_code, $notification_category)
{
    $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

    $db->query_delete('notifications_enabled', array(
        'l_notification_code' => substr($notification_code, 0, 80),
        'l_code_category' => is_null($notification_category) ? '' : $notification_category,
    ));
}

/**
 * Base class for notification hooks. Provides default implementations for all methods that provide full access to everyone, and interact with enabled table.
 *
 * @package    core_notifications
 */
class Hook_Notification
{
    /**
     * Get a list of all the notification codes this hook can handle.
     * (Addons can define hooks that handle whole sets of codes, so hooks are written so they can take wide authority)
     *
     * @return array List of codes (mapping between code names, and a pair: section and labelling for those codes)
     */
    public function list_handled_codes()
    {
        $list = array();
        $codename = preg_replace('#^Hook\_Notification\_#', '', strtolower(get_class($this)));
        $list[$codename] = array(do_lang('GENERAL'), do_lang('NOTIFICATION_TYPE_' . $codename));
        return $list;
    }

    /**
     * Find whether a handled notification code supports categories.
     * (Content types, for example, will define notifications on specific categories, not just in general. The categories are interpreted by the hook and may be complex. E.g. it might be like a regexp match, or like FORUM:3 or TOPIC:100)
     *
     * @param  ID_TEXT $notification_code Notification code
     * @return boolean Whether it does
     */
    public function supports_categories($notification_code)
    {
        return false;
    }

    /**
     * Standard function to create the standardised category tree. This base version will do it based on seeing what is already being monitored, i.e. so you can unmonitor them. It assumes monitoring is initially set from the frontend via the monitor button.
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?ID_TEXT $id The ID of where we're looking under (null: N/A)
     * @return array Tree structure
     */
    public function create_category_tree($notification_code, $id)
    {
        return $this->_create_category_tree($notification_code, $id, false);
    }

    /**
     * Standard function to create the standardised category tree. This base version will do it based on seeing what is already being monitored, i.e. so you can unmonitor them. It assumes monitoring is initially set from the frontend via the monitor button.
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?ID_TEXT $id The ID of where we're looking under (null: N/A)
     * @param  boolean $for_any_member Whether to list anything monitored by any member (useful if you are calling this because you can't naturally enumerate what can be monitored)
     * @return array Tree structure
     */
    protected function _create_category_tree($notification_code, $id, $for_any_member = false)
    {
        $page_links = array();

        $db = (substr($notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

        $notification_category = get_param_string('id', null);
        $done_in_url = is_null($notification_category);

        $map = array('l_notification_code' => substr($notification_code, 0, 80));
        if (!$for_any_member) {
            $map['l_member_id'] = get_member();
        }
        $types = $db->query_select('notifications_enabled', array('DISTINCT l_code_category'), $map, 'ORDER BY l_code_category DESC', 200/*reasonable limit*/); // Already monitoring members who may not be friends
        foreach ($types as $type) {
            if ($type['l_code_category'] != '') {
                $page_links[] = array(
                    'id' => $type['l_code_category'],
                    'title' => $type['l_code_category'],
                );
                if (!$done_in_url) {
                    if ($type['l_code_category'] == $notification_category) {
                        $done_in_url = true;
                    }
                }
            }
        }
        if (!$done_in_url) {
            $page_links[] = array(
                'id' => $notification_category,
                'title' => $notification_category,
            );
        }
        sort_maps_by($page_links, 'title');

        return $page_links;
    }

    /**
     * Find a bitmask of settings (email, SMS, etc) a notification code supports for listening on.
     *
     * @param  ID_TEXT $notification_code Notification code
     * @return integer Allowed settings
     */
    public function allowed_settings($notification_code)
    {
        return A__ALL;
    }

    /**
     * Find the initial setting that members have for a notification code (only applies to the member_could_potentially_enable members).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return integer Initial setting
     */
    public function get_initial_setting($notification_code, $category = null)
    {
        return A__STATISTICAL;
    }

    /**
     * Find the setting that members have for a notification code if they have done some action triggering automatic setting (e.g. posted within a topic).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return integer Automatic setting
     */
    public function get_default_auto_setting($notification_code, $category = null)
    {
        return A__STATISTICAL;
    }

    /**
     * Get a list of members who have enabled this notification (i.e. have permission to AND have chosen to or are defaulted to).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    public function list_members_who_have_enabled($notification_code, $category = null, $to_member_ids = null, $start = 0, $max = 300)
    {
        return $this->_all_members_who_have_enabled($notification_code, $category, $to_member_ids, $start, $max);
    }

    /**
     * Further filter results from _all_members_who_have_enabled.
     *
     * @param  array $to_filter Members from main query (we'll filter them)
     * @param  ID_TEXT $privilege The privilege
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_members_who_have_enabled_with_privilege($to_filter, $privilege, $only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max)
    {
        list($_members, $possibly_has_more) = $to_filter;
        $members = array();
        require_code('permissions');
        foreach ($_members as $member => $setting) {
            if (has_privilege($member, $privilege)) {
                $members[$member] = $setting;
            }
        }
        return array($members, $possibly_has_more);
    }

    /**
     * Further filter results from _all_members_who_have_enabled.
     *
     * @param  array $to_filter Members from main query (we'll filter them)
     * @param  ID_TEXT $zone The zone
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_members_who_have_enabled_with_zone_access($to_filter, $zone, $only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max)
    {
        list($_members, $possibly_has_more) = $to_filter;
        $members = array();
        foreach ($_members as $member => $setting) {
            if (has_zone_access($member, $zone)) {
                $members[$member] = $setting;
            }
        }
        return array($members, $possibly_has_more);
    }

    /**
     * Further filter results from _all_members_who_have_enabled.
     *
     * @param  array $to_filter Members from main query (we'll filter them)
     * @param  ID_TEXT $page The page
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_members_who_have_enabled_with_page_access($to_filter, $page, $only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max)
    {
        list($_members, $possibly_has_more) = $to_filter;
        $members = array();
        foreach ($_members as $member => $setting) {
            if (has_actual_page_access($member, $page)) {
                $members[$member] = $setting;
            }
        }
        return array($members, $possibly_has_more);
    }

    /**
     * Further filter results from _all_members_who_have_enabled.
     *
     * @param  array $to_filter Members from main query (we'll filter them)
     * @param  ID_TEXT $category The category permission type
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_members_who_have_enabled_with_category_access($to_filter, $category, $only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max)
    {
        list($_members, $possibly_has_more) = $to_filter;
        $members = array();
        foreach ($_members as $member => $setting) {
            if (has_category_access($member, $category, $only_if_enabled_on__category)) {
                $members[$member] = $setting;
            }
        }
        return array($members, $possibly_has_more);
    }

    /**
     * Find whether a member could enable this notification (i.e. have permission to).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  MEMBER $member_id Member to check against
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return boolean Whether they could
     */
    public function member_could_potentially_enable($notification_code, $member_id, $category = null)
    {
        return $this->_is_member(null, null, $member_id);
    }

    /**
     * Find whether a member has enabled this notification (i.e. have permission to AND have chosen to or are defaulted to).
     * (Separate implementation to list_members_who_have_enabled, for performance reasons.)
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  MEMBER $member_id Member to check against
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return boolean Whether they have
     */
    public function member_has_enabled($notification_code, $member_id, $category = null)
    {
        return $this->_is_member($notification_code, $category, $member_id);
    }

    /**
     * Get a list of members who have enabled this notification (i.e. have chosen to or are defaulted to).
     * (No pagination supported, as assumed there are only a small set of members here.)
     *
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @param  boolean $catch_all_too Whether to find members who are subscribed to the notification code for any category
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_members_who_have_enabled($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max, $catch_all_too = true)
    {
        global $NO_DB_SCOPE_CHECK;
        $bak = $NO_DB_SCOPE_CHECK;
        $NO_DB_SCOPE_CHECK = true;

        // We need to know the default, as this applies when there is no notifications_enabled row present and dictates our query algorithm
        $initial_setting = $this->get_initial_setting($only_if_enabled_on__notification_code, $only_if_enabled_on__category);

        // SQL: Notification code and category filters
        $clause_scope = ' AND ' . db_string_equal_to('l_notification_code', substr($only_if_enabled_on__notification_code, 0, 80));
        if ($only_if_enabled_on__category === null) {
            $clause_scope .= ' AND ' . db_string_equal_to('l_code_category', '');
        } elseif ($catch_all_too) {
            $clause_scope .= ' AND (' . db_string_equal_to('l_code_category', '') . ' OR ' . db_string_equal_to('l_code_category', $only_if_enabled_on__category) . ')';
        } else {
            $clause_scope .= ' AND ' . db_string_equal_to('l_code_category', $only_if_enabled_on__category);
        }

        // SQL: Member ID filters
        $clause_member_ids = '';
        if ($to_member_ids !== null) {
            if (count($to_member_ids) == 0) {
                return array(array(), false); // Optimisation: nothing to do
            }

            $clause_member_ids = ' AND (';
            foreach ($to_member_ids as $i => $member_id) {
                if ($i != 0) {
                    $clause_member_ids .= ' OR ';
                }
                $clause_member_ids .= 'l_member_id=' . strval($member_id);
            }
            $clause_member_ids .= ')';
        }
        $clause_member_ids_cns_side = str_replace('l_member_id', 'm.id', $clause_member_ids); // For when it's running on f_members instead of notifications_enabled

        // SQL: CNS member validation filters
        $clause_validation_cns = ' AND ' . db_string_equal_to('m_validated_email_confirm_code', '');
        if (addon_installed('unvalidated')) {
            $clause_validation_cns .= ' AND m_validated=1';
        }

        // Test lock-down status
        $lockdown_value = notification_locked_down($only_if_enabled_on__notification_code);
        if ($lockdown_value === 0) {
            // Locked down off, so we can bomb out now
            return array(array(), false);
        }

        // Which DB will we look at the notifications_enabled table on?
        $db = (substr($only_if_enabled_on__notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

        // Work out what query to do
        $is_cns = (get_forum_type() == 'cns');
        $is_locked_on = ($lockdown_value !== null);
        $has_by_default = ($initial_setting != A_NA); // Ignored if $is_locked_on
        if ($is_cns) {
            // This is the most obvious query for Conversr notification-queries
            $standard_query = 'SELECT l_member_id,l_setting FROM ' . $db->get_table_prefix() . 'notifications_enabled l JOIN ' . $db->get_table_prefix() . 'f_members m ON m.id=l.l_member_id WHERE 1=1';
            $standard_query .= $clause_scope . $clause_member_ids . $clause_validation_cns;
            $standard_query .= ' AND l_setting<>' . strval(A_NA);

            // Now go through the actual cases
            if ($is_locked_on) {
                // :-) We are just querying out all members so we find the member IDs
                $query = 'SELECT m.id AS l_member_id,' . strval($lockdown_value) . ' AS l_setting FROM ' . $db->get_table_prefix() . 'f_members m WHERE 1=1';
                $query .= $clause_member_ids_cns_side . $clause_validation_cns . ' AND m.id<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
            } else {
                if ($has_by_default) {
                    // :-) We are just querying out all members who do NOT have a setting of OFF
                    // We do a LEFT JOIN because having no setting is fine (it'll be put to the default setting of ON for the member)
                    $query = 'SELECT m.id AS l_member_id,l_setting FROM ' . $db->get_table_prefix() . 'f_members m LEFT JOIN ' . $db->get_table_prefix() . 'notifications_enabled l ON m.id=l.l_member_id' . $clause_scope . ' WHERE 1=1';
                    $query .= $clause_member_ids_cns_side . $clause_validation_cns . ' AND m.id<>' . strval($GLOBALS['FORUM_DRIVER']->get_guest_id());
                    $query .= ' AND (l_setting IS NULL OR l_setting<>' . strval(A_NA) . ')';
                } else {
                    // :-)
                    $query = $standard_query;
                }
            }
        } else {
            // This is the most obvious query for non-Conversr notification-queries
            $standard_query = 'SELECT l_member_id,l_setting FROM ' . $db->get_table_prefix() . 'notifications_enabled l WHERE 1=1';
            $standard_query .= $clause_scope . $clause_member_ids;
            $standard_query .= ' AND l_setting<>' . strval(A_NA);

            // Now go through the actual cases
            if ($is_locked_on) {
                // :-( For non-Conversr we have to fall-back to only doing explicit opt-in to notifications, as we are not going to be querying the member table directly
               $query = $standard_query;
            } else {
                if ($has_by_default) {
                    // :-( For non-Conversr we have to fall-back to only doing explicit opt-in to notifications, as we are not going to be querying the member table directly
                    $query = $standard_query;
                } else {
                    // :-) The query we can do for non-Conversr is fortunately perfect in this case
                    $query = $standard_query;
                }
            }
        }

        // Complete rows where settings are missing
        $results = $db->query($query, $max, $start);
        foreach ($results as $i => $r) {
            if ($results[$i]['l_setting'] === null) {
                $results[$i]['l_setting'] = $initial_setting;
            }
        }

        $NO_DB_SCOPE_CHECK = $bak;

        $possibly_has_more = (count($results) == $max);

        return array(collapse_2d_complexity('l_member_id', 'l_setting', $results), $possibly_has_more);
    }

    /**
     * Find whether someone has permission to view any notifications (yes) and possibly if they actually are.
     *
     * @param  ?ID_TEXT $only_if_enabled_on__notification_code Notification code (null: don't check if they are)
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  MEMBER $member_id Member to check against
     * @return boolean Whether they do
     */
    protected function _is_member($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $member_id)
    {
        if (is_null($only_if_enabled_on__notification_code)) {
            return true;
        }

        return notifications_enabled($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $member_id);
    }
}

/**
 * Derived abstract base class of notification hooks that provides only staff access.
 *
 * @package    core_notifications
 */
class Hook_notification__Staff extends Hook_Notification
{
    /**
     * Get a list of all the notification codes this hook can handle.
     * (Addons can define hooks that handle whole sets of codes, so hooks are written so they can take wide authority)
     *
     * @return array List of codes (mapping between code names, and a pair: section and labelling for those codes)
     */
    public function list_handled_codes()
    {
        $list = array();
        $codename = preg_replace('#^Hook\_Notification\_#', '', strtolower(get_class($this)));
        $list[$codename] = array(do_lang('STAFF'), do_lang('NOTIFICATION_TYPE_' . $codename));
        return $list;
    }

    /**
     * Get a list of members who have enabled this notification (i.e. have permission to AND have chosen to or are defaulted to).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    public function list_members_who_have_enabled($notification_code, $category = null, $to_member_ids = null, $start = 0, $max = 300)
    {
        return $this->_all_staff_who_have_enabled($notification_code, $category, $to_member_ids, $start, $max);
    }

    /**
     * Find whether a member could enable this notification (i.e. have permission to).
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  MEMBER $member_id Member to check against
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return boolean Whether they could
     */
    public function member_could_potentially_enable($notification_code, $member_id, $category = null)
    {
        return $this->_is_staff(null, null, $member_id);
    }

    /**
     * Find whether a member has enabled this notification (i.e. have permission to AND have chosen to or are defaulted to).
     * (Separate implementation to list_members_who_have_enabled, for performance reasons.)
     *
     * @param  ID_TEXT $notification_code Notification code
     * @param  MEMBER $member_id Member to check against
     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
     * @return boolean Whether they are
     */
    public function member_has_enabled($notification_code, $member_id, $category = null)
    {
        return $this->_is_staff($notification_code, $category, $member_id);
    }

    /**
     * Get a list of staff members who have enabled this notification (i.e. have permission to AND have chosen to or are defaulted to).
     *
     * @param  ID_TEXT $only_if_enabled_on__notification_code Notification code
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  ?array $to_member_ids List of member IDs we are restricting to (null: no restriction). This effectively works as a intersection set operator against those who have enabled.
     * @param  integer $start Start position (for pagination)
     * @param  integer $max Maximum (for pagination)
     * @return array A pair: Map of members to their notification setting, and whether there may be more
     */
    protected function _all_staff_who_have_enabled($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $to_member_ids, $start, $max)
    {
        $initial_setting = $this->get_initial_setting($only_if_enabled_on__notification_code, $only_if_enabled_on__category);

        $db = (substr($only_if_enabled_on__notification_code, 0, 4) == 'cns_') ? $GLOBALS['FORUM_DB'] : $GLOBALS['SITE_DB'];

        $admin_groups = array_merge($GLOBALS['FORUM_DRIVER']->get_super_admin_groups(), collapse_1d_complexity('group_id', $db->query_select('group_privileges', array('group_id'), array('privilege' => 'may_enable_staff_notifications'))));
        $rows = $GLOBALS['FORUM_DRIVER']->member_group_query($admin_groups, $max, $start);
        $possibly_has_more = (count($rows) >= $max);
        if (!is_null($to_member_ids)) {
            $new_rows = array();
            foreach ($rows as $row) {
                if (in_array($GLOBALS['FORUM_DRIVER']->mrow_id($row), $to_member_ids)) {
                    $new_rows[] = $row;
                }
            }
            $rows = $new_rows;
        }
        $new_rows = array();
        foreach ($rows as $row) {
            $test = notifications_setting($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $GLOBALS['FORUM_DRIVER']->mrow_id($row));

            if ($test != A_NA) {
                $new_rows[$GLOBALS['FORUM_DRIVER']->mrow_id($row)] = $test;
            }
        }

        return array($new_rows, $possibly_has_more);
    }

    /**
     * Find whether someone has permission to view staff notifications and possibly if they actually are.
     *
     * @param  ?ID_TEXT $only_if_enabled_on__notification_code Notification code (null: don't check if they are)
     * @param  ?SHORT_TEXT $only_if_enabled_on__category The category within the notification code (null: none)
     * @param  MEMBER $member_id Member to check against
     * @return boolean Whether they do
     */
    protected function _is_staff($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $member_id)
    {
        $test = is_null($only_if_enabled_on__notification_code) ? true : notifications_enabled($only_if_enabled_on__notification_code, $only_if_enabled_on__category, $member_id);

        require_code('permissions');
        return (($test) && (has_privilege($member_id, 'may_enable_staff_notifications')));
    }
}
