<?php
namespace BLR\Backline\Modules\Notifications;

use BLR\Backline\Core\Router;
use BLR\Backline\Core\SettingsPage;
use BLR\Backline\Modules\Events\Module as EventsModule;
use BLR\Backline\Modules\Outreach\Module as OutreachModule;
use BLR\Backline\Modules\Tasks\Module as TasksModule;

if (!defined('ABSPATH')) {
    exit;
}

final class Module {
    private const TABLE_SUFFIX = 'blr_ba_notifications';
    private const CACHE_GROUP = 'blr_backline_notifications';

    private static array $synced_user_ids = [];
    private static array $pending_deleted_post_sync_user_ids = [];
    private static array $queued_post_sync_ids = [];

    private const META_SYNC_KEYS = [
        'blr_task' => [
            'blr_task_status',
            'blr_task_priority',
            'blr_task_start_date',
            'blr_task_due_date',
            'blr_task_assignee_user_id',
            'blr_task_related_post_id',
            'blr_task_related_post_type',
            'blr_task_completed_at',
        ],
        'blr_event' => [
            'blr_event_status',
            'blr_event_start',
            'blr_event_end',
            'blr_venue_name',
        ],
        'blr_outreach' => [
            'blr_outreach_status',
            'blr_outreach_workflow_status',
            'blr_outreach_last_sent_at',
            'blr_outreach_sent_count',
            'blr_outreach_failed_count',
            'blr_outreach_follow_up_count',
            'blr_outreach_closed_count',
        ],
    ];

    public static function register_feature_only(): void {
        add_action('template_redirect', [self::class, 'maybe_mark_notification_read_from_link'], 1);
        add_filter('blr_backline_frontend_form_context', [self::class, 'handle_frontend_form_context'], 10, 2);
        add_action('save_post_blr_task', [self::class, 'handle_task_change'], 20, 3);
        add_action('save_post_blr_outreach', [self::class, 'handle_outreach_change'], 20, 3);
        add_action('save_post_blr_event', [self::class, 'handle_event_change'], 20, 3);
        add_action('before_delete_post', [self::class, 'capture_post_delete']);
        add_action('deleted_post', [self::class, 'handle_deleted_post']);
        add_action('trashed_post', [self::class, 'handle_post_status_change']);
        add_action('untrashed_post', [self::class, 'handle_post_status_change']);
        add_action('added_post_meta', [self::class, 'maybe_queue_sync_after_meta_change'], 10, 4);
        add_action('updated_post_meta', [self::class, 'maybe_queue_sync_after_meta_change'], 10, 4);
        add_action('deleted_post_meta', [self::class, 'maybe_queue_sync_after_meta_delete'], 10, 4);
        add_action('shutdown', [self::class, 'run_queued_post_syncs'], 20);
    }

    public static function install(): void {
        global $wpdb;

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        $table_name = self::table_name();
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE {$table_name} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            user_id bigint(20) unsigned NOT NULL,
            notification_key varchar(191) NOT NULL,
            fingerprint varchar(64) NOT NULL DEFAULT '',
            type varchar(50) NOT NULL DEFAULT '',
            source_post_id bigint(20) unsigned NOT NULL DEFAULT 0,
            source_post_type varchar(50) NOT NULL DEFAULT '',
            title varchar(255) NOT NULL DEFAULT '',
            message text NULL,
            url text NULL,
            icon varchar(50) NOT NULL DEFAULT 'bell',
            priority smallint(5) unsigned NOT NULL DEFAULT 50,
            event_time datetime NULL,
            is_read tinyint(1) NOT NULL DEFAULT 0,
            read_at datetime NULL,
            is_dismissed tinyint(1) NOT NULL DEFAULT 0,
            dismissed_at datetime NULL,
            created_at datetime NOT NULL,
            updated_at datetime NOT NULL,
            PRIMARY KEY  (id),
            UNIQUE KEY user_notification_key (user_id, notification_key),
            KEY user_open_priority (user_id, is_dismissed, is_read, priority),
            KEY source_post (source_post_type, source_post_id),
            KEY event_time (event_time)
        ) {$charset_collate};";

        dbDelta($sql);
    }

    public static function table_name(): string {
        global $wpdb;

        return $wpdb->prefix . self::TABLE_SUFFIX;
    }

    public static function register_nav_item(array $items, string $base, string $slug): array {
        unset($slug);

        $items[] = [
            'label' => __('Notifications', 'blr-backline'),
            'url' => $base . 'notifications/',
            'icon' => 'bell',
            'route' => 'notifications',
            'priority' => 85,
        ];

        return $items;
    }

    public static function sync_user_notifications(int $user_id, bool $force = false): void {
        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return;
        }

        if (!$force && isset(self::$synced_user_ids[$user_id])) {
            return;
        }

        if (!$force) {
            $rate_key = 'blr_ba_notif_sync_' . $user_id;
            if (get_transient($rate_key)) {
                self::$synced_user_ids[$user_id] = true;
                return;
            }
            set_transient($rate_key, 1, MINUTE_IN_SECONDS);
        }

        self::$synced_user_ids[$user_id] = true;

        $task_notifications = self::build_task_notifications($user_id);
        $outreach_notifications = self::build_outreach_notifications($user_id);
        $event_notifications = self::build_event_notifications($user_id);

        self::sync_type_notifications($user_id, 'task', $task_notifications);
        self::sync_type_notifications($user_id, 'outreach', $outreach_notifications);
        self::sync_type_notifications($user_id, 'event', $event_notifications);
        self::bump_user_cache_version($user_id);
    }

    public static function unread_count(int $user_id, bool $sync = true): int {
        global $wpdb;

        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return 0;
        }

        if ($sync) {
            self::sync_user_notifications($user_id);
        }

        $cache_key = self::cache_key('unread_count', $user_id);
        $cached = wp_cache_get($cache_key, self::CACHE_GROUP);
        if (false !== $cached) {
            return (int) $cached;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Lightweight unread notification count for the current user.
        $count = $wpdb->get_var(
            $wpdb->prepare(
                'SELECT COUNT(1)
                 FROM %i
                 WHERE user_id = %d
                   AND is_dismissed = 0
                   AND is_read = 0',
                self::table_name(),
                $user_id
            )
        );

        wp_cache_set($cache_key, (int) $count, self::CACHE_GROUP, MINUTE_IN_SECONDS);

        return (int) $count;
    }

    public static function list_for_user(int $user_id, array $args = [], bool $sync = true): array {
        global $wpdb;

        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return [];
        }

        $defaults = [
            'limit' => 10,
            'include_read' => true,
            'include_dismissed' => false,
            'type' => '',
        ];
        $args = wp_parse_args($args, $defaults);

        if ($sync) {
            self::sync_user_notifications($user_id);
        }

        $include_read = !empty($args['include_read']);
        $include_dismissed = !empty($args['include_dismissed']);
        $type = sanitize_key((string) $args['type']);
        $limit = max(1, min(200, absint($args['limit'])));

        $cache_key = self::cache_key(
            'list:' . md5(wp_json_encode([
                'user_id' => $user_id,
                'include_read' => $include_read,
                'include_dismissed' => $include_dismissed,
                'type' => $type,
                'limit' => $limit,
            ])),
            $user_id
        );
        $cached = wp_cache_get($cache_key, self::CACHE_GROUP);
        if (false !== $cached && is_array($cached)) {
            return $cached;
        }

        $rows = [];

        if ('' !== $type) {
            if ($include_dismissed) {
                if ($include_read) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND type = %s
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $type,
                            $limit
                        )
                    );
                } else {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_read = 0
                               AND type = %s
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $type,
                            $limit
                        )
                    );
                }
            } else {
                if ($include_read) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_dismissed = 0
                               AND type = %s
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $type,
                            $limit
                        )
                    );
                } else {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_dismissed = 0
                               AND is_read = 0
                               AND type = %s
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $type,
                            $limit
                        )
                    );
                }
            }
        } else {
            if ($include_dismissed) {
                if ($include_read) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $limit
                        )
                    );
                } else {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_read = 0
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $limit
                        )
                    );
                }
            } else {
                if ($include_read) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_dismissed = 0
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $limit
                        )
                    );
                } else {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Querying stored notifications for the current user.
                    $rows = $wpdb->get_results(
                        $wpdb->prepare(
                            'SELECT *
                             FROM %i
                             WHERE user_id = %d
                               AND is_dismissed = 0
                               AND is_read = 0
                             ORDER BY is_read ASC, priority DESC, COALESCE(event_time, updated_at) DESC, id DESC
                             LIMIT %d',
                            self::table_name(),
                            $user_id,
                            $limit
                        )
                    );
                }
            }
        }

        $rows = is_array($rows) ? $rows : [];
        wp_cache_set($cache_key, $rows, self::CACHE_GROUP, MINUTE_IN_SECONDS);

        return $rows;
    }

    public static function counts_for_user(int $user_id, bool $sync = true): array {
        global $wpdb;

        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return [
                'total' => 0,
                'unread' => 0,
                'read' => 0,
            ];
        }

        if ($sync) {
            self::sync_user_notifications($user_id);
        }

        $cache_key = self::cache_key('counts', $user_id);
        $cached = wp_cache_get($cache_key, self::CACHE_GROUP);
        if (false !== $cached && is_array($cached)) {
            return $cached;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Aggregated notification counts for a single user.
        $row = $wpdb->get_row(
            $wpdb->prepare(
                'SELECT
                    COUNT(1) AS total_count,
                    SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread_count,
                    SUM(CASE WHEN is_read = 1 THEN 1 ELSE 0 END) AS read_count
                 FROM %i
                 WHERE user_id = %d
                   AND is_dismissed = 0',
                self::table_name(),
                $user_id
            ),
            ARRAY_A
        );

        $counts = [
            'total' => (int) ($row['total_count'] ?? 0),
            'unread' => (int) ($row['unread_count'] ?? 0),
            'read' => (int) ($row['read_count'] ?? 0),
        ];

        wp_cache_set($cache_key, $counts, self::CACHE_GROUP, MINUTE_IN_SECONDS);

        return $counts;
    }

    public static function mark_read(int $notification_id, int $user_id): void {
        self::update_notification_state($notification_id, $user_id, [
            'is_read' => 1,
            'read_at' => current_time('mysql'),
        ]);
    }

    public static function dismiss(int $notification_id, int $user_id): void {
        self::update_notification_state($notification_id, $user_id, [
            'is_dismissed' => 1,
            'dismissed_at' => current_time('mysql'),
        ]);
    }

    public static function mark_all_read(int $user_id): void {
        global $wpdb;

        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return;
        }

        $now = current_time('mysql');

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk state update for the current user's notifications; cache invalidated via user cache version bump.
        $wpdb->query(
            $wpdb->prepare(
                'UPDATE %i
                 SET is_read = 1,
                     read_at = %s,
                     updated_at = %s
                 WHERE user_id = %d
                   AND is_dismissed = 0
                   AND is_read = 0',
                self::table_name(),
                $now,
                $now,
                $user_id
            )
        );

        self::bump_user_cache_version($user_id);
    }

    public static function dismiss_read(int $user_id): void {
        global $wpdb;

        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return;
        }

        $now = current_time('mysql');

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk dismissal for already-read notifications; cache invalidated via user cache version bump.
        $wpdb->query(
            $wpdb->prepare(
                'UPDATE %i
                 SET is_dismissed = 1,
                     dismissed_at = %s,
                     updated_at = %s
                 WHERE user_id = %d
                   AND is_dismissed = 0
                   AND is_read = 1',
                self::table_name(),
                $now,
                $now,
                $user_id
            )
        );

        self::bump_user_cache_version($user_id);
    }

    public static function action_nonce(): string {
        return 'blr_ba_notification_action';
    }

    public static function link_url(object $notification): string {
        $url = isset($notification->url) ? (string) $notification->url : '';
        if ($url === '') {
            $url = self::page_url();
        }

        $notification_id = isset($notification->id) ? absint($notification->id) : 0;
        if ($notification_id <= 0) {
            return $url;
        }

        return add_query_arg([
            'blr_notification' => $notification_id,
            '_blr_notification_nonce' => wp_create_nonce('blr_ba_notification_link_' . $notification_id),
        ], $url);
    }

    public static function page_url(): string {
        return Router::base_url() . 'notifications/';
    }

    public static function type_label(string $type): string {
        switch ($type) {
            case 'task':
                return __('Task', 'blr-backline');
            case 'outreach':
                return __('Outreach', 'blr-backline');
            case 'event':
                return __('Event', 'blr-backline');
            default:
                return __('Notification', 'blr-backline');
        }
    }

    public static function priority_badge_class(int $priority): string {
        if ($priority >= 90) {
            return 'danger';
        }
        if ($priority >= 75) {
            return 'warning';
        }
        if ($priority >= 60) {
            return 'info';
        }

        return 'secondary';
    }

    public static function relative_time(?string $datetime): string {
        $datetime = is_string($datetime) ? trim($datetime) : '';
        if ($datetime === '') {
            return __('Just now', 'blr-backline');
        }

        $timestamp = strtotime($datetime);
        if (!$timestamp) {
            return $datetime;
        }

        $current_timestamp = current_time('timestamp');
        $relative = human_time_diff($current_timestamp, $timestamp);

        if ($timestamp >= $current_timestamp) {
            return sprintf(
                /* translators: %s: relative amount of time. */
                __('In %s', 'blr-backline'),
                $relative
            );
        }

        return sprintf(
            /* translators: %s: relative amount of time. */
            __('%s ago', 'blr-backline'),
            $relative
        );
    }

    public static function maybe_mark_notification_read_from_link(): void {
        if (!is_user_logged_in()) {
            return;
        }

        $notification_id = isset($_GET['blr_notification']) ? absint((string) wp_unslash($_GET['blr_notification'])) : 0;
        $nonce = isset($_GET['_blr_notification_nonce']) ? sanitize_text_field((string) wp_unslash($_GET['_blr_notification_nonce'])) : '';

        if ($notification_id <= 0 || $nonce === '') {
            return;
        }

        if (!wp_verify_nonce($nonce, 'blr_ba_notification_link_' . $notification_id)) {
            return;
        }

        self::mark_read($notification_id, get_current_user_id());
    }

    public static function handle_frontend_form_context(array $ctx, string $submitted_action): array {
        if (!is_user_logged_in()) {
            return $ctx;
        }

        $allowed_actions = [
            'blr_ba_notification_mark_read',
            'blr_ba_notification_dismiss',
            'blr_ba_notification_mark_all_read',
            'blr_ba_notification_dismiss_read',
        ];

        if (!in_array($submitted_action, $allowed_actions, true)) {
            return $ctx;
        }

        $nonce = isset($_POST['_wpnonce']) ? sanitize_text_field((string) wp_unslash($_POST['_wpnonce'])) : '';
        if ($nonce === '' || !wp_verify_nonce($nonce, self::action_nonce())) {
            $ctx['errors'][] = __('Security check failed.', 'blr-backline');
            return $ctx;
        }

        $user_id = get_current_user_id();

        if ('blr_ba_notification_mark_read' === $submitted_action) {
            $notification_id = absint((string) wp_unslash($_POST['blr_notification_id'] ?? 0));
            self::mark_read($notification_id, $user_id);
        } elseif ('blr_ba_notification_dismiss' === $submitted_action) {
            $notification_id = absint((string) wp_unslash($_POST['blr_notification_id'] ?? 0));
            self::dismiss($notification_id, $user_id);
        } elseif ('blr_ba_notification_mark_all_read' === $submitted_action) {
            self::mark_all_read($user_id);
        } elseif ('blr_ba_notification_dismiss_read' === $submitted_action) {
            self::dismiss_read($user_id);
        }

        wp_safe_redirect(self::page_url());
        exit;
    }

    public static function handle_task_change(int $post_id, \WP_Post $post, bool $update): void {
        unset($update);

        if (wp_is_post_revision($post_id) || 'blr_task' !== $post->post_type) {
            return;
        }

        self::sync_users_for_post($post);
    }

    public static function handle_outreach_change(int $post_id, \WP_Post $post, bool $update): void {
        unset($update);

        if (wp_is_post_revision($post_id) || 'blr_outreach' !== $post->post_type) {
            return;
        }

        self::sync_users_for_post($post);
    }

    public static function handle_event_change(int $post_id, \WP_Post $post, bool $update): void {
        unset($update);

        if (wp_is_post_revision($post_id) || 'blr_event' !== $post->post_type) {
            return;
        }

        self::sync_users_for_post($post);
    }

    public static function capture_post_delete(int $post_id): void {
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            return;
        }

        $user_ids = self::notification_sync_user_ids_for_post($post);
        if (!empty($user_ids)) {
            self::$pending_deleted_post_sync_user_ids[$post_id] = $user_ids;
        }
    }

    public static function handle_deleted_post(int $post_id): void {
        $user_ids = self::$pending_deleted_post_sync_user_ids[$post_id] ?? [];
        unset(self::$pending_deleted_post_sync_user_ids[$post_id]);

        foreach ($user_ids as $user_id) {
            self::sync_user_notifications((int) $user_id, true);
        }
    }

    public static function handle_post_status_change(int $post_id): void {
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            return;
        }

        self::sync_users_for_post($post);
    }

    public static function maybe_queue_sync_after_meta_change($meta_id, $object_id, $meta_key, $_meta_value): void {
        unset($meta_id, $_meta_value);

        self::queue_post_sync_if_relevant((int) $object_id, (string) $meta_key);
    }

    public static function maybe_queue_sync_after_meta_delete($meta_ids, $object_id, $meta_key, $_meta_value): void {
        unset($meta_ids, $_meta_value);

        self::queue_post_sync_if_relevant((int) $object_id, (string) $meta_key);
    }

    public static function run_queued_post_syncs(): void {
        if (empty(self::$queued_post_sync_ids)) {
            return;
        }

        $post_ids = array_values(array_unique(array_map('absint', self::$queued_post_sync_ids)));
        self::$queued_post_sync_ids = [];

        foreach ($post_ids as $post_id) {
            if ($post_id <= 0) {
                continue;
            }

            $post = get_post($post_id);
            if ($post instanceof \WP_Post) {
                self::sync_users_for_post($post);
            }
        }
    }

    private static function queue_post_sync_if_relevant(int $post_id, string $meta_key): void {
        $post_id = absint($post_id);
        $meta_key = sanitize_key($meta_key);
        if ($post_id <= 0 || $meta_key === '') {
            return;
        }

        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            return;
        }

        $relevant_keys = self::META_SYNC_KEYS[$post->post_type] ?? [];
        if (!in_array($meta_key, $relevant_keys, true)) {
            return;
        }

        self::$queued_post_sync_ids[] = $post_id;
    }

    private static function sync_type_notifications(int $user_id, string $type, array $notifications): void {
        $keys = [];

        foreach ($notifications as $notification) {
            if (!is_array($notification)) {
                continue;
            }

            $notification['type'] = $type;
            $notification['user_id'] = $user_id;
            $notification['notification_key'] = isset($notification['notification_key']) ? sanitize_key((string) $notification['notification_key']) : '';
            if ('' === $notification['notification_key']) {
                continue;
            }

            $keys[] = $notification['notification_key'];
            self::upsert($notification);
        }

        self::delete_missing_keys($user_id, $type, $keys);
    }

    private static function build_task_notifications(int $user_id): array {
        if (!self::user_can_receive_global_notifications($user_id)) {
            return [];
        }

        $now = new \DateTimeImmutable('now', wp_timezone());
        $today = $now->format('Y-m-d');
        $soon_cutoff = $now->modify('+3 days')->format('Y-m-d');

        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Shared task attention scan; bounded to avoid dashboard-scale regressions.
        $tasks = get_posts([
            'post_type' => 'blr_task',
            'post_status' => ['publish', 'draft'],
            'numberposts' => 200,
            'orderby' => 'modified',
            'order' => 'DESC',
            'meta_query' => [[
                'key' => 'blr_task_status',
                'value' => 'Done',
                'compare' => '!=',
            ]],
        ]);

        $notifications = [];

        foreach ($tasks as $task) {
            if (!$task instanceof \WP_Post || !self::task_is_relevant_to_user($task, $user_id)) {
                continue;
            }

            $task_id = (int) $task->ID;
            $status = (string) get_post_meta($task_id, 'blr_task_status', true);
            $priority = (string) get_post_meta($task_id, 'blr_task_priority', true);
            $due_date = (string) get_post_meta($task_id, 'blr_task_due_date', true);
            $related_id = (int) get_post_meta($task_id, 'blr_task_related_post_id', true);
            $related_label = $related_id > 0 ? TasksModule::related_label($related_id) : '';

            $title = '';
            $message = '';
            $notification_priority = 60;
            $icon = 'check-square';
            $event_time = $task->post_modified_gmt;

            if ('' !== $due_date && $due_date < $today) {
                $title = __('Task overdue', 'blr-backline');
                $message = get_the_title($task);
                if ('' !== $related_label) {
                    $message .= ' · ' . $related_label;
                }
                $message .= ' · ' . sprintf(
                    /* translators: %s: due date. */
                    __('Was due %s', 'blr-backline'),
                    $due_date
                );
                $notification_priority = 95;
                $icon = 'alert-triangle';
                $event_time = $due_date . ' 00:00:00';
            } elseif ('' !== $due_date && $due_date === $today) {
                $title = __('Task due today', 'blr-backline');
                $message = get_the_title($task);
                if ('' !== $related_label) {
                    $message .= ' · ' . $related_label;
                }
                $notification_priority = 85;
                $icon = 'clock';
                $event_time = $due_date . ' 00:00:00';
            } elseif ('Blocked' === $status) {
                $title = __('Task blocked', 'blr-backline');
                $message = get_the_title($task);
                if ('' !== $related_label) {
                    $message .= ' · ' . $related_label;
                }
                $notification_priority = 88;
                $icon = 'slash';
            } elseif ('' !== $due_date && $due_date > $today && $due_date <= $soon_cutoff) {
                $title = __('Task coming up', 'blr-backline');
                $message = get_the_title($task);
                if ('' !== $related_label) {
                    $message .= ' · ' . $related_label;
                }
                $message .= ' · ' . sprintf(
                    /* translators: %s: due date. */
                    __('Due %s', 'blr-backline'),
                    $due_date
                );
                $notification_priority = 'Urgent' === $priority ? 84 : 70;
                $icon = 'calendar';
                $event_time = $due_date . ' 00:00:00';
            }

            if ('' === $title) {
                continue;
            }

            $notifications[] = [
                'notification_key' => 'task_attention_' . $task_id,
                'source_post_id' => $task_id,
                'source_post_type' => 'blr_task',
                'title' => $title,
                'message' => $message,
                'url' => Router::base_url() . 'tasks/view/' . $task_id . '/',
                'icon' => $icon,
                'priority' => $notification_priority,
                'event_time' => $event_time,
            ];
        }

        return $notifications;
    }

    private static function build_outreach_notifications(int $user_id): array {
        if (!self::user_can_receive_global_notifications($user_id)) {
            return [];
        }

        $campaigns = get_posts([
            'post_type' => 'blr_outreach',
            'post_status' => ['publish', 'draft'],
            'numberposts' => 200,
            'orderby' => 'modified',
            'order' => 'DESC',
        ]);

        $notifications = [];

        foreach ($campaigns as $campaign) {
            $campaign_id = (int) $campaign->ID;
            $resolved_status = OutreachModule::resolved_campaign_status($campaign_id);
            $last_sent_at = (string) get_post_meta($campaign_id, 'blr_outreach_last_sent_at', true);
            $event_time = '' !== $last_sent_at ? $last_sent_at : $campaign->post_modified_gmt;

            if ('Failed' === $resolved_status || 'Partial' === $resolved_status) {
                $notifications[] = [
                    'notification_key' => 'outreach_issue_' . $campaign_id,
                    'source_post_id' => $campaign_id,
                    'source_post_type' => 'blr_outreach',
                    'title' => __('Outreach delivery issue', 'blr-backline'),
                    'message' => get_the_title($campaign) . ' · ' . OutreachModule::status_label($resolved_status),
                    'url' => Router::base_url() . 'outreach/view/' . $campaign_id . '/',
                    'icon' => 'send',
                    'priority' => 90,
                    'event_time' => $event_time,
                ];
            } elseif ('Follow-up Needed' === $resolved_status) {
                $notifications[] = [
                    'notification_key' => 'outreach_follow_up_' . $campaign_id,
                    'source_post_id' => $campaign_id,
                    'source_post_type' => 'blr_outreach',
                    'title' => __('Outreach follow-up needed', 'blr-backline'),
                    'message' => get_the_title($campaign),
                    'url' => Router::base_url() . 'outreach/view/' . $campaign_id . '/',
                    'icon' => 'mail',
                    'priority' => 78,
                    'event_time' => $event_time,
                ];
            }
        }

        return $notifications;
    }

    private static function build_event_notifications(int $user_id): array {
        if (!self::user_can_receive_global_notifications($user_id)) {
            return [];
        }

        $now = new \DateTimeImmutable('now', wp_timezone());
        $today_datetime = $now->format('Y-m-d H:i');
        $week_ahead = $now->modify('+7 days')->format('Y-m-d H:i');

        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Limited upcoming events query used to sync shared event notifications.
        $events = get_posts([
            'post_type' => 'blr_event',
            'post_status' => ['publish', 'draft'],
            'numberposts' => 200,
            'meta_key' => 'blr_event_start',
            'orderby' => ['meta_value' => 'ASC', 'date' => 'ASC'],
            'meta_type' => 'CHAR',
            'meta_query' => [[
                'key' => 'blr_event_start',
                'value' => [$today_datetime, $week_ahead],
                'compare' => 'BETWEEN',
                'type' => 'CHAR',
            ]],
        ]);

        $notifications = [];

        foreach ($events as $event) {
            $event_id = (int) $event->ID;
            $event_status = EventsModule::normalize_status((string) get_post_meta($event_id, 'blr_event_status', true));
            $start = (string) get_post_meta($event_id, 'blr_event_start', true);
            $venue_name = (string) get_post_meta($event_id, 'blr_venue_name', true);

            if ('cancelled' === $event_status) {
                $notifications[] = [
                    'notification_key' => 'event_cancelled_' . $event_id,
                    'source_post_id' => $event_id,
                    'source_post_type' => 'blr_event',
                    'title' => __('Event cancelled', 'blr-backline'),
                    'message' => get_the_title($event),
                    'url' => Router::base_url() . 'events/view/' . $event_id . '/',
                    'icon' => 'x-circle',
                    'priority' => 88,
                    'event_time' => '' !== $start ? $start . ':00' : $event->post_modified_gmt,
                ];
                continue;
            }

            $message = get_the_title($event);
            if ('' !== $venue_name) {
                $message .= ' · ' . $venue_name;
            }
            if ('' !== $start) {
                $message .= ' · ' . $start;
            }

            $notifications[] = [
                'notification_key' => 'event_upcoming_' . $event_id,
                'source_post_id' => $event_id,
                'source_post_type' => 'blr_event',
                'title' => __('Upcoming event', 'blr-backline'),
                'message' => $message,
                'url' => Router::base_url() . 'events/view/' . $event_id . '/',
                'icon' => 'calendar',
                'priority' => 68,
                'event_time' => '' !== $start ? $start . ':00' : $event->post_modified_gmt,
            ];
        }

        return $notifications;
    }

    private static function task_is_relevant_to_user(\WP_Post $task, int $user_id): bool {
        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return false;
        }

        $assignee_user_id = (int) get_post_meta((int) $task->ID, 'blr_task_assignee_user_id', true);
        return $assignee_user_id === $user_id || (int) $task->post_author === $user_id;
    }

    private static function sync_users_for_post(\WP_Post $post): void {
        foreach (self::notification_sync_user_ids_for_post($post) as $user_id) {
            self::sync_user_notifications((int) $user_id, true);
        }
    }

    private static function notification_sync_user_ids_for_post(\WP_Post $post): array {
        if (!in_array($post->post_type, ['blr_task', 'blr_outreach', 'blr_event'], true)) {
            return [];
        }

        return self::accessible_user_ids();
    }

    private static function user_can_receive_global_notifications(int $user_id): bool {
        if (user_can($user_id, 'manage_options')) {
            return true;
        }

        return in_array($user_id, SettingsPage::get_allowed_user_ids(), true);
    }

    private static function accessible_user_ids(): array {
        $ids = [];

        foreach ((array) get_users(['role__in' => ['administrator'], 'fields' => 'ID']) as $user_id) {
            $ids[] = (int) $user_id;
        }

        foreach (SettingsPage::get_allowed_user_ids() as $user_id) {
            $ids[] = (int) $user_id;
        }

        $ids = array_values(array_unique(array_filter($ids)));
        sort($ids);

        return $ids;
    }

    private static function upsert(array $notification): void {
        global $wpdb;

        $user_id = absint($notification['user_id'] ?? 0);
        $notification_key = sanitize_key((string) ($notification['notification_key'] ?? ''));
        if ($user_id <= 0 || '' === $notification_key) {
            return;
        }

        $title = sanitize_text_field((string) ($notification['title'] ?? ''));
        $message = sanitize_text_field((string) ($notification['message'] ?? ''));
        $url = esc_url_raw((string) ($notification['url'] ?? ''));
        $icon = sanitize_key((string) ($notification['icon'] ?? 'bell'));
        $priority = max(1, min(100, absint($notification['priority'] ?? 50)));
        $event_time = self::normalize_mysql_datetime((string) ($notification['event_time'] ?? ''));
        $source_post_id = absint($notification['source_post_id'] ?? 0);
        $source_post_type = sanitize_key((string) ($notification['source_post_type'] ?? ''));
        $type = sanitize_key((string) ($notification['type'] ?? ''));
        $fingerprint = md5(wp_json_encode([
            $title,
            $message,
            $url,
            $icon,
            $priority,
            $event_time,
        ]));
        $now = current_time('mysql');

        $existing_cache_key = self::cache_key('existing:' . md5($user_id . '|' . $notification_key), $user_id);
        $existing = wp_cache_get($existing_cache_key, self::CACHE_GROUP);

        if (false === $existing) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Looking up an existing notification row before deciding whether to insert or update.
            $existing = $wpdb->get_row(
                $wpdb->prepare(
                    'SELECT id, fingerprint
                     FROM %i
                     WHERE user_id = %d
                       AND notification_key = %s
                     LIMIT 1',
                    self::table_name(),
                    $user_id,
                    $notification_key
                ),
                ARRAY_A
            );
            wp_cache_set($existing_cache_key, $existing, self::CACHE_GROUP, MINUTE_IN_SECONDS);
        }

        $data = [
            'user_id' => $user_id,
            'notification_key' => $notification_key,
            'fingerprint' => $fingerprint,
            'type' => $type,
            'source_post_id' => $source_post_id,
            'source_post_type' => $source_post_type,
            'title' => $title,
            'message' => $message,
            'url' => $url,
            'icon' => $icon,
            'priority' => $priority,
            'event_time' => $event_time,
            'updated_at' => $now,
        ];

        $formats = ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s'];

        if (empty($existing)) {
            $data['created_at'] = $now;
            $data['is_read'] = 0;
            $data['is_dismissed'] = 0;

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Insert row into the notification store.
            $wpdb->insert(
                self::table_name(),
                $data,
                array_merge($formats, ['%s', '%d', '%d'])
            );

            wp_cache_delete($existing_cache_key, self::CACHE_GROUP);
            self::bump_user_cache_version($user_id);
            return;
        }

        if ((string) ($existing['fingerprint'] ?? '') !== $fingerprint) {
            $data['is_read'] = 0;
            $data['read_at'] = null;
            $data['is_dismissed'] = 0;
            $data['dismissed_at'] = null;
            $formats = array_merge($formats, ['%d', '%s', '%d', '%s']);
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Update existing notification state.
        $wpdb->update(
            self::table_name(),
            $data,
            ['id' => (int) $existing['id']],
            $formats,
            ['%d']
        );

        wp_cache_delete($existing_cache_key, self::CACHE_GROUP);
        self::bump_user_cache_version($user_id);
    }

    private static function delete_missing_keys(int $user_id, string $type, array $keys): void {
        global $wpdb;

        $user_id = absint($user_id);
        $type = sanitize_key($type);
        if ($user_id <= 0 || '' === $type) {
            return;
        }

        if (empty($keys)) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Clearing stale notifications for a type when no active keys remain; cache invalidated via user cache version bump.
            $wpdb->query(
                $wpdb->prepare(
                    'DELETE FROM %i
                     WHERE user_id = %d
                       AND type = %s',
                    self::table_name(),
                    $user_id,
                    $type
                )
            );

            self::bump_user_cache_version($user_id);
            return;
        }

        $normalized_keys = array_values(array_unique(array_filter(array_map('sanitize_key', $keys))));
        if (empty($normalized_keys)) {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Pulling current keys for a user/type pair before removing stale rows; cache invalidated via user cache version bump.
        $existing_keys = $wpdb->get_col(
            $wpdb->prepare(
                'SELECT notification_key
                 FROM %i
                 WHERE user_id = %d
                   AND type = %s',
                self::table_name(),
                $user_id,
                $type
            )
        );

        if (!is_array($existing_keys) || empty($existing_keys)) {
            return;
        }

        $stale_keys = array_diff(array_map('strval', $existing_keys), $normalized_keys);
        if (empty($stale_keys)) {
            return;
        }

        foreach ($stale_keys as $stale_key) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting a stale notification row by its unique user/key pair; cache invalidated via user cache version bump.
            $wpdb->delete(
                self::table_name(),
                [
                    'user_id' => $user_id,
                    'type' => $type,
                    'notification_key' => sanitize_key((string) $stale_key),
                ],
                ['%d', '%s', '%s']
            );
        }

        self::bump_user_cache_version($user_id);
    }

    private static function update_notification_state(int $notification_id, int $user_id, array $changes): void {
        global $wpdb;

        $notification_id = absint($notification_id);
        $user_id = absint($user_id);
        if ($notification_id <= 0 || $user_id <= 0) {
            return;
        }

        $changes['updated_at'] = current_time('mysql');

        $formats = [];
        foreach ($changes as $key => $value) {
            if (in_array($key, ['is_read', 'is_dismissed'], true)) {
                $formats[] = '%d';
            } else {
                $formats[] = '%s';
            }
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- State update for a single notification owned by the current user; cache invalidated via user cache version bump.
        $wpdb->update(
            self::table_name(),
            $changes,
            [
                'id' => $notification_id,
                'user_id' => $user_id,
            ],
            $formats,
            ['%d', '%d']
        );

        self::bump_user_cache_version($user_id);
    }

    private static function normalize_mysql_datetime(string $value): ?string {
        $value = trim($value);
        if ('' === $value) {
            return null;
        }

        $timestamp = strtotime($value);
        if (!$timestamp) {
            return null;
        }

        return gmdate('Y-m-d H:i:s', $timestamp);
    }

    private static function cache_key(string $suffix, int $user_id): string {
        return 'user_' . $user_id . ':v' . self::cache_version($user_id) . ':' . $suffix;
    }

    private static function cache_version(int $user_id): int {
        $version = wp_cache_get('version:' . $user_id, self::CACHE_GROUP);
        if (false === $version) {
            $version = 1;
            wp_cache_set('version:' . $user_id, $version, self::CACHE_GROUP, DAY_IN_SECONDS);
        }

        return (int) $version;
    }

    private static function bump_user_cache_version(int $user_id): void {
        $user_id = absint($user_id);
        if ($user_id <= 0) {
            return;
        }

        $new_version = time();
        wp_cache_set('version:' . $user_id, $new_version, self::CACHE_GROUP, DAY_IN_SECONDS);
    }
}
