<?php
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Notifications use a plugin-owned custom table built from the trusted WordPress prefix; all user-controlled values are sanitized and prepared.
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Service for handling business logic of notifications (who to email, when, and checking user preferences).
 */
class NEXDIRECTORY_Notification_Service {
    const TABLE_SUFFIX = 'nexdirectory_notifications';

    /**
     * Notification types rendered by the member notification centre.
     *
     * @var array<string, bool>
     */
    private $notification_type_labels = [
        'message'           => true,
        'claim_submitted'   => true,
        'claim_status'      => true,
        'moderation'        => true,
        'team_invite'       => true,
        'team_update'       => true,
        'rsvp'              => true,
        'waitlist'          => true,
        'waitlist_promoted' => true,
        'system'            => true,
    ];

    /**
     * @var NEXDIRECTORY_Email_Service
     */
    private $email_service;

    /**
     * @var NEXDIRECTORY_Date_Helper
     */
    private $date_helper;

    /**
     * Constructor.
     *
     * @param NEXDIRECTORY_Email_Service $email_service Service for rendering and sending emails.
     * @param NEXDIRECTORY_Date_Helper   $date_helper   Service for formatting dates in emails.
     */
    public function __construct($email_service, $date_helper) {
        $this->email_service = $email_service;
        $this->date_helper = $date_helper;
    }

    /**
     * Returns the plugin-owned notification table.
     *
     * @return string
     */
    private function get_table_name() {
        global $wpdb;
        return $wpdb->prefix . self::TABLE_SUFFIX;
    }

    /**
     * Truncates a plain-text notification field without requiring mbstring.
     *
     * @param string $value Source value.
     * @param int    $length Maximum characters.
     * @return string
     */
    private function truncate_text($value, $length) {
        $value = sanitize_text_field((string) $value);
        $length = max(1, absint((int) $length));
        if (function_exists('mb_substr')) {
            return (string) mb_substr($value, 0, $length, 'UTF-8');
        }
        return (string) substr($value, 0, $length);
    }

    /**
     * Returns a translatable label for a notification type.
     *
     * @param string $type Notification type.
     * @return string
     */
    private function get_notification_type_label($type) {
        switch (sanitize_key((string) $type)) {
            case 'message':
                return __('Message', 'nexdirectory');
            case 'claim_submitted':
                return __('Claim', 'nexdirectory');
            case 'claim_status':
                return __('Claim update', 'nexdirectory');
            case 'moderation':
                return __('Moderation', 'nexdirectory');
            case 'team_invite':
                return __('Team invitation', 'nexdirectory');
            case 'team_update':
                return __('Team update', 'nexdirectory');
            case 'rsvp':
                return __('RSVP', 'nexdirectory');
            case 'waitlist':
                return __('Waitlist', 'nexdirectory');
            case 'waitlist_promoted':
                return __('Waitlist update', 'nexdirectory');
            default:
                return __('Update', 'nexdirectory');
        }
    }

    /**
     * Adds an account notification.
     *
     * A non-empty dedupe key makes event hooks idempotent while allowing callers
     * to intentionally create multiple notifications of the same type.
     *
     * @param array<string, mixed> $args Notification fields.
     * @return int|false Inserted notification ID, existing ID, or false.
     */
    public function create_notification(array $args) {
        global $wpdb;

        $recipient_id = absint((int) ($args['recipient_id'] ?? 0));
        if ($recipient_id <= 0 || !get_user_by('id', $recipient_id)) {
            return false;
        }

        $type = sanitize_key((string) ($args['type'] ?? 'system'));
        if (!array_key_exists($type, $this->notification_type_labels)) {
            $type = 'system';
        }

        $title = $this->truncate_text((string) ($args['title'] ?? ''), 191);
        if ($title === '') {
            $title = __('Account update', 'nexdirectory');
        }

        $body = trim((string) wp_strip_all_tags((string) ($args['body'] ?? ''), true));
        if (function_exists('mb_substr')) {
            $body = (string) mb_substr($body, 0, 1200, 'UTF-8');
        } else {
            $body = (string) substr($body, 0, 1200);
        }

        $dedupe_key = $this->truncate_text((string) ($args['dedupe_key'] ?? ''), 191);
        $dedupe_token = $dedupe_key !== ''
            ? hash('sha256', (string) $recipient_id . "\0" . $dedupe_key)
            : '';
        $table = $this->get_table_name();
        if ($dedupe_key !== '') {
            // Preserve idempotency for notifications created before the atomic
            // dedupe token column was introduced.
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
            $existing_id = absint((int) $wpdb->get_var(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                    "SELECT id FROM {$table} WHERE recipient_id = %d AND dedupe_key = %s LIMIT 1",
                    $recipient_id,
                    $dedupe_key
                )
            ));
            if ($existing_id > 0) {
                return $existing_id;
            }
        }

        // A nullable token allows unlimited non-deduplicated notifications,
        // while the unique recipient/token index serializes identical events.
        // LAST_INSERT_ID(id) returns the winning row to every concurrent caller.
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
        $inserted = $wpdb->query(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                "INSERT INTO {$table}
                    (recipient_id, actor_id, type, object_id, secondary_id, title, body, url, dedupe_key, dedupe_token, created_at, read_at)
                 VALUES
                    (%d, %d, %s, %d, %d, %s, %s, %s, %s, NULLIF(%s, ''), %s, NULL)
                 ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)",
                $recipient_id,
                absint((int) ($args['actor_id'] ?? 0)),
                $type,
                absint((int) ($args['object_id'] ?? 0)),
                absint((int) ($args['secondary_id'] ?? 0)),
                $title,
                $body,
                esc_url_raw((string) ($args['url'] ?? '')),
                $dedupe_key,
                $dedupe_token,
                current_time('mysql', true)
            )
        );

        return $inserted === false ? false : absint((int) $wpdb->insert_id);
    }

    /**
     * Returns a recipient-owned notification feed.
     *
     * @param int $user_id Recipient user ID.
     * @param int $limit Maximum rows.
     * @param int $before_id Optional cursor.
     * @return array<int, array<string, mixed>>
     */
    public function get_notifications_for_user($user_id, $limit = 20, $before_id = 0) {
        global $wpdb;

        $user_id = absint((int) $user_id);
        $limit = max(1, min(50, absint((int) $limit)));
        $before_id = absint((int) $before_id);
        if ($user_id <= 0) {
            return [];
        }

        $table = $this->get_table_name();
        if ($before_id > 0) {
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                    "SELECT id, actor_id, type, object_id, secondary_id, title, body, url, created_at, read_at
                    FROM {$table}
                    WHERE recipient_id = %d AND id < %d
                    ORDER BY id DESC
                    LIMIT %d",
                    $user_id,
                    $before_id,
                    $limit
                ),
                ARRAY_A
            );
        } else {
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                    "SELECT id, actor_id, type, object_id, secondary_id, title, body, url, created_at, read_at
                    FROM {$table}
                    WHERE recipient_id = %d
                    ORDER BY id DESC
                    LIMIT %d",
                    $user_id,
                    $limit
                ),
                ARRAY_A
            );
        }

        if (!is_array($rows)) {
            return [];
        }

        return array_values(array_map([$this, 'prepare_notification_for_response'], $rows));
    }

    /**
     * Returns the unread notification count for one recipient.
     *
     * @param int $user_id Recipient user ID.
     * @return int
     */
    public function get_notification_unread_count($user_id) {
        global $wpdb;
        $user_id = absint((int) $user_id);
        if ($user_id <= 0) {
            return 0;
        }

        $table = $this->get_table_name();
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
        return absint((int) $wpdb->get_var(
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                "SELECT COUNT(*) FROM {$table} WHERE recipient_id = %d AND read_at IS NULL",
                $user_id
            )
        ));
    }

    /**
     * Marks recipient-owned notifications as read.
     *
     * @param int        $user_id Recipient user ID.
     * @param array<int> $notification_ids IDs to mark. Empty marks all.
     * @return int Number of changed rows.
     */
    public function mark_notifications_read($user_id, array $notification_ids = []) {
        global $wpdb;
        $user_id = absint((int) $user_id);
        if ($user_id <= 0) {
            return 0;
        }

        $table = $this->get_table_name();
        $read_at = current_time('mysql', true);
        $clean_notification_ids = [];
        foreach ($notification_ids as $notification_id) {
            if (!is_scalar($notification_id)) {
                continue;
            }
            $notification_id = absint((int) $notification_id);
            if ($notification_id > 0) {
                $clean_notification_ids[] = $notification_id;
                if (count($clean_notification_ids) >= 100) {
                    break;
                }
            }
        }
        $notification_ids = array_values(array_unique($clean_notification_ids));

        if (empty($notification_ids)) {
            // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name uses the trusted WordPress prefix.
            $changed = $wpdb->query(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name uses the trusted WordPress prefix.
                    "UPDATE {$table} SET read_at = %s WHERE recipient_id = %d AND read_at IS NULL",
                    $read_at,
                    $user_id
                )
            );
            return is_int($changed) ? max(0, $changed) : 0;
        }

        $placeholders = implode(',', array_fill(0, count($notification_ids), '%d'));
        $query_args = array_merge([$read_at, $user_id], $notification_ids);
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned table name and generated numeric placeholders are trusted.
        $changed = $wpdb->query(
            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The variadic replacement count matches the generated numeric placeholders.
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned table name and generated numeric placeholders are trusted.
                "UPDATE {$table}
                SET read_at = %s
                WHERE recipient_id = %d AND read_at IS NULL AND id IN ({$placeholders})",
                ...$query_args
            )
        );

        return is_int($changed) ? max(0, $changed) : 0;
    }

    /**
     * Converts a stored row into the minimal frontend representation.
     *
     * @param array<string, mixed> $row Database row.
     * @return array<string, mixed>
     */
    private function prepare_notification_for_response($row) {
        $row = is_array($row) ? $row : [];
        $actor_id = absint((int) ($row['actor_id'] ?? 0));
        $actor_name = '';
        $actor_avatar = '';
        if ($actor_id > 0) {
            $actor = get_user_by('id', $actor_id);
            if ($actor instanceof WP_User) {
                $public_name = sanitize_text_field((string) get_user_meta($actor_id, 'nexdirectory_profile_public_name', true));
                $display_name = sanitize_text_field((string) $actor->display_name);
                $login_name = sanitize_text_field((string) $actor->user_login);
                if ($public_name !== '') {
                    $actor_name = $public_name;
                } elseif ($display_name !== '' && ($login_name === '' || !hash_equals($login_name, $display_name))) {
                    $actor_name = $display_name;
                } else {
                    $actor_name = __('Member', 'nexdirectory');
                }

                // Avoid Gravatar's email-derived URL and only return the avatar
                // the member explicitly supplied for their Nex Directory profile.
                $actor_avatar = esc_url_raw((string) get_user_meta($actor_id, 'nexdirectory_profile_avatar_url', true));
            }
        }

        $type = sanitize_key((string) ($row['type'] ?? 'system'));
        if (!array_key_exists($type, $this->notification_type_labels)) {
            $type = 'system';
        }
        $created_at = sanitize_text_field((string) ($row['created_at'] ?? ''));
        $created_timestamp = $created_at !== '' ? strtotime($created_at . ' UTC') : false;
        $created_label = '';
        if ($created_timestamp) {
            /* translators: %s: Human-readable time difference. */
            $created_label = sprintf(__('%s ago', 'nexdirectory'), human_time_diff($created_timestamp, current_time('timestamp', true)));
        }

        return [
            'id' => absint((int) ($row['id'] ?? 0)),
            'type' => $type,
            'type_label' => $this->get_notification_type_label($type),
            'object_id' => absint((int) ($row['object_id'] ?? 0)),
            'secondary_id' => absint((int) ($row['secondary_id'] ?? 0)),
            'title' => sanitize_text_field((string) ($row['title'] ?? '')),
            'body' => sanitize_textarea_field((string) ($row['body'] ?? '')),
            'url' => esc_url_raw((string) ($row['url'] ?? '')),
            'created_at' => $created_at,
            'created_label' => $created_label,
            'is_unread' => empty($row['read_at']),
            'actor' => [
                'id' => $actor_id,
                'name' => $actor_name,
                'avatar_url' => $actor_avatar,
            ],
        ];
    }

    /**
     * Checks if a specific notification toggle is enabled in the platform settings.
     */
    public function is_notification_toggle_enabled($option_key, $default = '1', $compat_option = '') {
        $value = get_option($option_key, null);

        if (($value === null || $value === false || $value === '') && $compat_option !== '') {
            $value = get_option($compat_option, null);
        }
        if ($value === null || $value === false || $value === '') {
            $value = $default;
        }
        
        return (string) $value === '1';
    }

    /**
     * Translates an internal post status to a human-readable badge/label for emails.
     */
    public function event_status_badge($status) {
        $labels = [
            'pending' => __('Pending Review', 'nexdirectory'),
            'publish' => __('Published', 'nexdirectory'),
            'draft'   => __('Draft', 'nexdirectory'),
            'trash'   => __('Deleted', 'nexdirectory'),
        ];

        return $labels[$status] ?? ucfirst($status);
    }

    /**
     * Notifies the platform administrator when a new listing is submitted and pending review.
     */
    public function notify_admin_submission($post_id) {
        if (!$this->is_notification_toggle_enabled(
            'nexdirectory_notify_admin_pending_email',
            '1',
            'nexdirectory_enable_pending_admin_notifications'
        )) {
            return;
        }

        $admin_email = get_option('admin_email');
        $post = get_post($post_id);
        
        if (!$post || !$admin_email) {
            return;
        }

        // Prevent duplicate emails for the same post entering pending status multiple times
        if (get_post_meta((int) $post_id, 'nexdirectory_admin_pending_notified', true) === '1') {
            return;
        }

        $pending_link = admin_url('admin.php?page=nexdirectory-moderation');
        /* translators: %s: Placeholder value. */
        $subject = sprintf(__('New event pending review: %s', 'nexdirectory'), $post->post_title);
        
        $fallback_text = sprintf(
            "A new event is waiting for review.\n\nTitle: %s\nAuthor: %s\nEdit: %s",
            $post->post_title,
            get_the_author_meta('user_email', (int) $post->post_author),
            admin_url('post.php?post=' . $post_id . '&action=edit')
        );
        $fallback_text .= "\nModeration Queue: " . $pending_link;

        $this->email_service->send_platform_email($admin_email, $subject, '', [
            'fallback_text' => $fallback_text,
        ]);

        update_post_meta((int) $post_id, 'nexdirectory_admin_pending_notified', '1');
    }

    /**
     * Notifies the listing author when their listing's moderation status changes.
     */
    public function notify_author_status($post_id, $status, $note = '') {
        $post = get_post($post_id);
        if (!$post) {
            return;
        }
        
        $author = get_user_by('id', (int) $post->post_author);
        if (!$author) {
            return;
        }

        // Check platform toggles based on the specific status transition
        if ($status === 'pending' && !$this->is_notification_toggle_enabled('nexdirectory_notify_author_confirmation', '1')) {
            return;
        }
        if ($status === 'publish' && !$this->is_notification_toggle_enabled('nexdirectory_notify_author_update', '1')) {
            return;
        }
        if ($status !== 'pending' && $status !== 'publish' && !$this->is_notification_toggle_enabled('nexdirectory_notify_author_cancellation', '1')) {
            return;
        }

        $status_label = $this->event_status_badge($status);
        /* translators: %s: Placeholder value. */
        $subject_default = sprintf(__('Your event status changed: %s', 'nexdirectory'), $post->post_title);
        $custom_templates_enabled = function_exists('nexdirectory_is_pro_feature_enabled')
            && nexdirectory_is_pro_feature_enabled('notification_automation');
        $allow_event_template_override = $custom_templates_enabled
            && (string) get_option('nexdirectory_enable_per_event_email_templates', '0') === '1';
        
        $subject_option = '';
        $body_option = '';
        $template_name = '';

        if ($status === 'pending') {
            $subject_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_subject_confirmation', true) : '';
            if ($subject_option === '') {
                $subject_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_subject_confirmation', $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_confirmation'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_confirmation');
            }
            $body_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_body_confirmation', true) : '';
            if ($body_option === '') {
                $body_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_body_confirmation', $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_confirmation'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_confirmation');
            }
            $template_name = 'confirmation.php';
            
        } elseif ($status === 'publish') {
            $subject_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_subject_update', true) : '';
            if ($subject_option === '') {
                $subject_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_subject_update', $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_update'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_update');
            }
            $body_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_body_update', true) : '';
            if ($body_option === '') {
                $body_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_body_update', $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_update'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_update');
            }
            $template_name = 'update.php';
            
        } else {
            $subject_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_subject_cancellation', true) : '';
            if ($subject_option === '') {
                $subject_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_subject_cancellation', $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_cancellation'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_subject_cancellation');
            }
            $body_option = $allow_event_template_override ? (string) get_post_meta($post_id, 'nexdirectory_email_body_cancellation', true) : '';
            if ($body_option === '') {
                $body_option = $custom_templates_enabled
                    ? (string) get_option('nexdirectory_email_body_cancellation', $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_cancellation'))
                    : $this->email_service->get_default_event_email_template_value('nexdirectory_email_body_cancellation');
            }
            $template_name = 'cancellation.php';
        }

        $rendered_subject = trim((string) $this->email_service->render_event_email_subject($subject_option, $post_id, (string) $status_label));
        $subject = $rendered_subject !== '' ? $rendered_subject : $subject_default;
        
        $custom_body_html = $this->email_service->render_event_email_body($body_option, $post_id, (string) $status_label, (string) $note);
        
        $fallback_text = sprintf(
            "Status: %s\nEvent: %s\nLink: %s\n\nModerator note: %s",
            $status_label,
            $post->post_title,
            get_permalink($post_id),
            $note ? $note : __('No note provided.', 'nexdirectory')
        );

        $this->email_service->send_platform_email($author->user_email, $subject, $template_name, [
            'user'             => $author,
            'post_id'          => $post_id,
            'event_title'      => get_the_title($post_id),
            'event_date'       => $this->date_helper->format_event_date($post_id),
            'event_url'        => get_permalink($post_id),
            'status_label'     => $status_label,
            'moderator_note'   => $note,
            'custom_body_html' => $custom_body_html,
            'fallback_text'    => $fallback_text,
        ]);
    }

    /**
     * Sends an email verification link to a user.
     */
    public function send_verification_email($user_id) {
        $user = get_user_by('id', (int) $user_id);
        if (!$user) {
            return false;
        }

        $token = wp_generate_password(48, false, false);
        update_user_meta($user_id, 'nexdirectory_email_verify_token', $token);
        update_user_meta($user_id, 'nexdirectory_email_verify_expires', (string) (time() + 2 * DAY_IN_SECONDS));

        $verify_url = nexdirectory_add_query_arg([
            'nexdirectory_verify_email' => (int) $user_id,
            'token'            => rawurlencode($token),
        ], home_url('/'));
        
        $subject = __('Verify your email before first event submission', 'nexdirectory');
        $fallback = sprintf(
            "Hi %s,\n\nPlease verify your email to enable your first event submission:\n%s\n\nThis link expires in 48 hours.",
            $user->display_name ?: $user->user_login,
            $verify_url
        );
        
        return $this->email_service->send_platform_email($user->user_email, $subject, 'verification.php', [
            'user'          => $user,
            'verify_url'    => $verify_url,
            'expires_label' => __('48 hours', 'nexdirectory'),
            'fallback_text' => $fallback,
        ]);
    }
}
