<?php

namespace WPSN\Modules\Notifications;

use WPSN\Core\Container;
use WPSN\Core\Http\HttpClient;
use WPSN\Modules\Audit\AuditRunner;
use WPSN\Modules\Diagnostics\PlatformOpsDiffBuilder;

defined('ABSPATH') || exit;

final class NotificationsJobs
{
    public const JOB_SEND = 'notify_send';
    public const JOB_SEND_DIGEST = 'notify_send_digest';

    private Container $container;
    private NotificationsRepository $repo;

    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->repo = new NotificationsRepository();
    }

    private function settings()
    {
        return $this->container->get('settings');
    }

    private function queue()
    {
        return $this->container->get('queue');
    }

    private function logger()
    {
        return $this->container->get('logger');
    }

    private function enabled(): bool
    {
        return (bool) $this->settings()->get('notifications.enabled', true);
    }

    private function sourceEnabled(string $sourceKey, bool $default = true): bool
    {
        $v = $this->settings()->get('notifications.sources.' . $sourceKey, $default);
        return (bool) $v;
    }

    private function minSeverity(): string
    {
        $s = (string) $this->settings()->get('notifications.min_severity', 'warn');
        $s = sanitize_key($s);
        if (!in_array($s, ['info', 'warn', 'critical'], true)) {
            $s = 'warn';
        }
        return $s;
    }

    private function sourceMinSeverity(string $source): string
    {
        $source = sanitize_key($source);
        if ($source === '') {
            return $this->minSeverity();
        }

        $cfg = $this->settings()->get('notifications.rules.sources.' . $source . '.min_severity', '');
        $cfg = sanitize_key((string) $cfg);
        if (!in_array($cfg, ['info', 'warn', 'critical'], true)) {
            return $this->minSeverity();
        }
        return $cfg;
    }

    private function sourceDedupeWindowMinutes(string $source): int
    {
        $source = sanitize_key($source);
        if ($source === '') {
            return 0;
        }

        $win = (int) $this->settings()->get('notifications.rules.sources.' . $source . '.dedupe_window_minutes', 0);
        return max(0, min(10080, $win));
    }

    private function applyDedupeWindowToKey(string $eventKey, string $source): string
    {
        $win = $this->sourceDedupeWindowMinutes($source);
        if ($win <= 0) {
            return $eventKey;
        }

        // Time-bucketed suffix (UTC) so we can send at most once per window
        // for "similar" events (based on the same base event_key).
        $now = time();
        $bucket = (int) (floor($now / ($win * 60)) * ($win * 60));
        $suffix = ':b' . gmdate('YmdHi', $bucket);

        $full = $eventKey . $suffix;

        // Keep DB `event_key` under 191 chars.
        if (strlen($full) > 191) {
            $hash = md5($full);
            $prefix = substr($eventKey, 0, 150);
            $full = $prefix . ':' . $hash;
        }

        return $full;
    }

    private function shouldSendForSource(string $source, string $sev): bool
    {
        return $this->severityRank($sev) >= $this->severityRank($this->sourceMinSeverity($source));
    }

    private function sendBudgetPerHour(): int
    {
        $b = (int) $this->settings()->get('notifications.rules.send_budget_per_hour', 30);
        return max(0, min(500, $b));
    }

    private function sendBudgetKey(): string
    {
        return 'wpsn_notify_budget_' . gmdate('YmdH');
    }

    /**
     * @return int seconds to wait (0 = OK to send)
     */
    private function checkSendBudget(string $severity): int
    {
        $budget = $this->sendBudgetPerHour();
        if ($budget <= 0) {
            return 0;
        }

        // Always allow CRITICAL notifications to bypass the budget.
        if ($this->severityRank($severity) >= $this->severityRank('critical')) {
            return 0;
        }

        $key = $this->sendBudgetKey();
        $count = (int) get_transient($key);
        if ($count < $budget) {
            return 0;
        }

        // Wait until next hour bucket.
        $now = time();
        $next = ((int) (floor($now / HOUR_IN_SECONDS) + 1)) * HOUR_IN_SECONDS;
        return max(60, min(3600, $next - $now));
    }

    private function bumpSendBudget(): void
    {
        $budget = $this->sendBudgetPerHour();
        if ($budget <= 0) {
            return;
        }

        $key = $this->sendBudgetKey();
        $count = (int) get_transient($key);
        $count = max(0, $count) + 1;
        set_transient($key, $count, 2 * HOUR_IN_SECONDS);
    }

    private function severityRank(string $sev): int
    {
        $sev = sanitize_key($sev);
        if ($sev === 'critical') {
            return 3;
        }
        if ($sev === 'warn' || $sev === 'warning') {
            return 2;
        }
        return 1;
    }

    private function normalizeOperationalSeverity(string $sev): string
    {
        $sev = sanitize_key($sev);
        if ($sev === 'warning') {
            $sev = 'warn';
        }
        if (!in_array($sev, ['ok', 'warn', 'critical'], true)) {
            return 'info';
        }

        return $sev;
    }

    private function shouldSend(string $sev): bool
    {
        // Back-compat helper (global threshold). Prefer shouldSendForSource().
        return $this->severityRank($sev) >= $this->severityRank($this->minSeverity());
    }

    /**
     * Create a new notification record and enqueue delivery (best-effort).
     *
     * @param array<string,mixed> $meta
     */
    private function createEvent(string $eventKey, string $source, string $type, string $severity, string $title, string $message, string $url = '', array $meta = [], bool $deliver = true): int
    {
        if (!$this->enabled()) {
            return 0;
        }

        // Ensure DB is available.
        if (!$this->repo->tableExists()) {
            return 0;
        }

        $eventKey = $this->applyDedupeWindowToKey($eventKey, $source);

        $eventId = $this->repo->insert([
            'event_key' => $eventKey,
            'source' => $source,
            'type' => $type,
            'severity' => $severity,
            'title' => $title,
            'message' => $message,
            'url' => $url,
            'meta' => $meta,
        ]);

        if ($eventId > 0 && $deliver && $this->shouldSendForSource($source, $severity)) {
            try {
                $this->queue()->enqueue(self::JOB_SEND, ['event_id' => $eventId]);
            } catch (\Throwable $e) {
                // ignore
            }
        }

        return $eventId;
    }

    private function integrationMode(string $key): string
    {
        $mode = (string) $this->settings()->get('notifications.integrations.' . $key . '.mode', 'off');
        $mode = sanitize_key($mode);
        if (!in_array($mode, ['off', 'inbox', 'route'], true)) {
            $mode = 'off';
        }
        return $mode;
    }

    private function canRouteToCenter(): bool
    {
        // If module is disabled, do not break legacy notifications.
        if (!$this->settings()->moduleEnabled('notifications', true)) {
            return false;
        }
        // If notifications center is globally disabled, treat as unavailable for routing.
        return (bool) $this->settings()->get('notifications.enabled', true);
    }

    /**
     * Integration: Drift Monitor → Notifications Center
     *
     * Fired by DriftMonitorService on drift event creation.
     *
     * @param array<string,mixed> $item
     * @param array<string,mixed> $extra
     */
    public function onDriftMonitorEventCreated(int $driftEventId, array $item, string $eventType, string $severity, string $title, string $message, array $extra = []): void
    {
        $mode = $this->integrationMode('drift_monitor');
        if ($mode === 'off') {
            return;
        }

        $deliver = ($mode === 'route') && $this->canRouteToCenter();

        $eventType = sanitize_key($eventType);
        if (!in_array($eventType, ['head', 'gsc', 'snippet'], true)) {
            $eventType = 'drift';
        }

        $severity = sanitize_key($severity);
        if (!in_array($severity, ['info', 'warn', 'warning', 'critical'], true)) {
            $severity = 'info';
        }
        if ($severity === 'warning') {
            $severity = 'warn';
        }

        $itemId = isset($item['id']) ? (int) $item['id'] : 0;
        $label = isset($item['label']) ? (string) $item['label'] : '';
        $urlIn = isset($item['url']) ? (string) $item['url'] : '';
        $objType = isset($item['object_type']) ? sanitize_key((string) $item['object_type']) : '';

        $eventKey = 'drift:' . $eventType . ':' . max(0, (int) $driftEventId);

        $short = $label !== '' ? $label : ($urlIn !== '' ? $urlIn : ('#' . $itemId));
        $nTitle = sprintf(__('Drift detected (%s): %s', 'wp-seo-next'), strtoupper($eventType), $short);

        // Prefer the original drift-generated title/message for detail.
        $nMsg = $message !== '' ? $message : ($title !== '' ? $title : __('Drift detected.', 'wp-seo-next'));

        $tab = 'head';
        if ($eventType === 'gsc') {
            $tab = 'rules';
        } elseif ($eventType === 'snippet') {
            $tab = 'snippet';
        }

        $adminUrl = admin_url('tools.php?page=wpsn-drift-monitor&tab=' . rawurlencode($tab));

        $meta = [
            'integration' => 'drift_monitor',
            'drift_event_id' => (int) $driftEventId,
            'drift_event_type' => $eventType,
            'drift_item_id' => $itemId,
            'object_type' => $objType,
            'label' => sanitize_text_field($label),
            'url' => esc_url_raw($urlIn),
            // Keep extra small; avoid leaking large payloads.
            'extra_keys' => is_array($extra) ? array_slice(array_map('strval', array_keys($extra)), 0, 20) : [],
        ];

        $this->createEvent($eventKey, 'drift_monitor', 'drift_' . $eventType, $severity, $nTitle, $nMsg, $adminUrl, $meta, $deliver);
    }

    /**
     * Integration: GSC Rank Tracking digest → Notifications Center
     *
     * Fired by RankTrackingJobs once digest content is assembled.
     *
     * @param array<string,mixed> $digest
     */
    public function onGscAlertDigestBuilt(array $digest): void
    {
        $mode = $this->integrationMode('gsc_digest');
        if ($mode === 'off') {
            return;
        }
        if (!empty($digest['preview'])) {
            return;
        }

        $deliver = ($mode === 'route') && $this->canRouteToCenter();

        $property = isset($digest['property']) ? (string) $digest['property'] : '';
        $period = isset($digest['period']) ? sanitize_key((string) $digest['period']) : 'daily';
        if (!in_array($period, ['daily', 'weekly'], true)) {
            $period = 'daily';
        }

        $startDay = isset($digest['start_day']) ? (string) $digest['start_day'] : '';
        $endDay = isset($digest['end_day']) ? (string) $digest['end_day'] : '';
        $total = isset($digest['total']) ? (int) $digest['total'] : 0;

        if ($property === '' || $endDay === '' || $total <= 0) {
            return;
        }

        $hasCritical = !empty($digest['has_critical']);
        $severity = $hasCritical ? 'critical' : 'warn';

        $pKey = md5($property);
        $eventKey = 'gsc_digest:' . $period . ':' . $endDay . ':' . $pKey;

        $title = ($period === 'weekly')
            ? sprintf(__('GSC weekly alerts digest: %d', 'wp-seo-next'), $total)
            : sprintf(__('GSC alerts digest: %d', 'wp-seo-next'), $total);

        $msg = ($period === 'weekly' && $startDay !== '')
            ? sprintf(__('Property: %s · Range: %s..%s', 'wp-seo-next'), $property, $startDay, $endDay)
            : sprintf(__('Property: %s · Day: %s', 'wp-seo-next'), $property, $endDay);

        $adminUrl = admin_url('tools.php?page=wpsn-search-console');

        $subject = isset($digest['subject']) ? (string) $digest['subject'] : '';
        $emailBody = isset($digest['email_body']) ? (string) $digest['email_body'] : '';
        $slackText = isset($digest['slack_text']) ? (string) $digest['slack_text'] : '';

        $meta = [
            'integration' => 'gsc_digest',
            'property' => sanitize_text_field($property),
            'period' => $period,
            'start_day' => sanitize_text_field($startDay),
            'end_day' => sanitize_text_field($endDay),
            'total' => (int) $total,
            // Channel-specific overrides for Notifications Center sender.
            'email_subject' => $subject !== '' ? sanitize_text_field($subject) : '',
            'email_body' => $emailBody !== '' ? (string) $emailBody : '',
            'slack_text' => $slackText !== '' ? (string) $slackText : '',
        ];

        $this->createEvent($eventKey, 'search_console', 'gsc_alert_digest_' . $period, $severity, $title, $msg, $adminUrl, $meta, $deliver);
    }

    /**
     * Source: Queue job failures.
     *
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     */
    public function onQueueJobFailed(int $jobId, string $jobType, array $payload, array $job, string $errorMessage): void
    {
        if (!$this->sourceEnabled('queue_failed', true)) {
            return;
        }

        $jobId = max(0, $jobId);
        $jobType = sanitize_key($jobType);

        $eventKey = 'queue_failed:' . $jobId;

        $title = sprintf(__('Queue job failed: %s', 'wp-seo-next'), $jobType !== '' ? $jobType : __('unknown', 'wp-seo-next'));
        $message = sanitize_text_field($errorMessage !== '' ? $errorMessage : __('Job failed.', 'wp-seo-next'));

        $payloadKeys = [];
        try {
            $payloadKeys = array_slice(array_map('strval', array_keys($payload)), 0, 25);
        } catch (\Throwable $e) {
            $payloadKeys = [];
        }

        $meta = [
            'job_id' => $jobId,
            'job_type' => $jobType,
            'attempts' => isset($job['attempts']) ? (int) $job['attempts'] : 0,
            'status' => isset($job['status']) ? sanitize_key((string) $job['status']) : '',
            'payload_keys' => $payloadKeys,
        ];

        $url = admin_url('tools.php?page=wpsn-diagnostics');

        $this->createEvent($eventKey, 'queue', $jobType, 'warn', $title, $message, $url, $meta);
    }

    /**
     * Source: Head diff detected after plugin update.
     *
     * @param array<string,mixed> $notice
     */
    public function onHeadDiffDetected(array $notice): void
    {
        if (!$this->sourceEnabled('head_diff', true)) {
            return;
        }

        $from = isset($notice['from_version']) ? (string) $notice['from_version'] : '';
        $to = isset($notice['to_version']) ? (string) $notice['to_version'] : '';
        $baselineId = isset($notice['baseline_snapshot_id']) ? (int) $notice['baseline_snapshot_id'] : 0;

        $changed = isset($notice['changed']) && is_array($notice['changed']) ? $notice['changed'] : [];

        $severity = 'warn';
        $criticalSignals = [];

        foreach ($changed as $c) {
            if (!is_array($c)) {
                continue;
            }
            $summary = $c['diff_summary'] ?? null;
            if (is_array($summary)) {
                $sample = isset($summary['sample']) && is_array($summary['sample']) ? $summary['sample'] : [];
                foreach ($sample as $k) {
                    $k = (string) $k;
                    if ($k === 'meta.robots' || $k === 'meta.name:robots' || $k === 'meta.robots.content' || $k === 'link.rel:canonical' || $k === 'link.canonical' || $k === 'title') {
                        $criticalSignals[] = $k;
                    }
                }
            }

            // Also inspect resolved robots/canonical when present.
            $baseRes = isset($c['baseline_resolved']) && is_array($c['baseline_resolved']) ? $c['baseline_resolved'] : [];
            $curRes = isset($c['current_resolved']) && is_array($c['current_resolved']) ? $c['current_resolved'] : [];

            $baseRobots = isset($baseRes['robots']) ? (string) $baseRes['robots'] : '';
            $curRobots  = isset($curRes['robots']) ? (string) $curRes['robots'] : '';
            if ($baseRobots !== '' && $curRobots !== '' && $baseRobots !== $curRobots) {
                $criticalSignals[] = 'robots_changed';
            }

            $baseCanon = isset($baseRes['canonical']) ? (string) $baseRes['canonical'] : '';
            $curCanon  = isset($curRes['canonical']) ? (string) $curRes['canonical'] : '';
            if ($baseCanon !== '' && $curCanon !== '' && $baseCanon !== $curCanon) {
                $criticalSignals[] = 'canonical_changed';
            }
        }

        $criticalSignals = array_values(array_unique($criticalSignals));
        if (!empty($criticalSignals)) {
            $severity = 'critical';
        }

        $eventKey = 'head_diff:' . ($to !== '' ? $to : gmdate('Ymd')) . ':' . $baselineId;

        $title = __('SEO output changed after update', 'wp-seo-next');
        if ($from !== '' || $to !== '') {
            $title = sprintf(__('SEO output changed after update (%s → %s)', 'wp-seo-next'), $from !== '' ? $from : '?', $to !== '' ? $to : '?');
        }

        $caseLabels = [];
        foreach ($changed as $c) {
            if (!is_array($c)) {
                continue;
            }
            $lbl = isset($c['label']) ? (string) $c['label'] : (isset($c['key']) ? (string) $c['key'] : '');
            if ($lbl !== '') {
                $caseLabels[] = $lbl;
            }
        }
        $caseLabels = array_slice(array_values(array_unique($caseLabels)), 0, 10);

        $messageParts = [];
        if (!empty($caseLabels)) {
            $messageParts[] = sprintf(__('Changed contexts: %s', 'wp-seo-next'), implode(', ', $caseLabels));
        }
        if (!empty($criticalSignals)) {
            $messageParts[] = sprintf(__('Signals: %s', 'wp-seo-next'), implode(', ', array_slice($criticalSignals, 0, 8)));
        }
        if (empty($messageParts)) {
            $messageParts[] = __('Head snapshot diff detected.', 'wp-seo-next');
        }
        $message = implode(' · ', $messageParts);

        $url = admin_url('tools.php?page=wpsn-snapshots&tab=head');

        $meta = [
            'from_version' => $from,
            'to_version' => $to,
            'baseline_snapshot_id' => $baselineId,
            'changed_count' => is_array($changed) ? count($changed) : 0,
            'critical_signals' => $criticalSignals,
        ];

        $this->createEvent($eventKey, 'head_diff', 'head_diff', $severity, $title, $message, $url, $meta);
    }

    /**
     * Source: Audit scan completed.
     *
     * @param array<string,mixed> $summary
     */
    public function onAuditScanCompleted(int $scanId, array $summary): void
    {
        if (!$this->sourceEnabled('audit_critical', true)) {
            return;
        }

        $counts = isset($summary['issues_by_code']) && is_array($summary['issues_by_code']) ? $summary['issues_by_code'] : [];
        $total = isset($summary['issues_total']) ? (int) $summary['issues_total'] : 0;

        // Only notify on issues we consider high-signal.
        $criticalCodes = [
            AuditRunner::ISSUE_NOINDEX,
            AuditRunner::ISSUE_CANONICAL_EXTERNAL,
            AuditRunner::ISSUE_TITLE_TEMPLATE_BROKEN,
            AuditRunner::ISSUE_NEWS_SITEMAP_EMPTY,
            AuditRunner::ISSUE_VIDEO_SITEMAP_EMPTY,
            AuditRunner::ISSUE_LOCAL_NAP_MISMATCH_SETTINGS,
        ];

        $warnCodes = [
            AuditRunner::ISSUE_CANONICAL_INVALID,
            AuditRunner::ISSUE_DESCRIPTION_TEMPLATE_BROKEN,
            AuditRunner::ISSUE_NEWS_SITEMAP_MISSING_POST_TITLE,
            AuditRunner::ISSUE_VIDEO_SITEMAP_INVALID_URL,
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_THUMBNAIL,
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_TITLE,
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_DESCRIPTION,
            AuditRunner::ISSUE_VIDEO_SITEMAP_INVALID_DURATION,
            AuditRunner::ISSUE_LOCAL_HOME_MISSING_BUSINESS_INFO,
            AuditRunner::ISSUE_LOCAL_LOCATION_MISSING_FIELDS,
            AuditRunner::ISSUE_LOCAL_LOCATION_MISSING_GEO,
        ];

        $critHit = [];
        foreach ($criticalCodes as $code) {
            $c = isset($counts[$code]) ? (int) $counts[$code] : 0;
            if ($c > 0) {
                $critHit[$code] = $c;
            }
        }

        $warnHit = [];
        foreach ($warnCodes as $code) {
            $c = isset($counts[$code]) ? (int) $counts[$code] : 0;
            if ($c > 0) {
                $warnHit[$code] = $c;
            }
        }

        if (empty($critHit) && empty($warnHit)) {
            return; // avoid spamming on low-signal audit issues
        }

        $severity = !empty($critHit) ? 'critical' : 'warn';

        $eventKey = 'audit_scan:' . max(0, $scanId);

        $title = __('Audit scan detected critical issues', 'wp-seo-next');
        $msg = sprintf(__('Total issues: %d', 'wp-seo-next'), max(0, $total));

        $parts = [$msg];
        if (!empty($critHit)) {
            $pairs = [];
            foreach ($critHit as $code => $c) {
                $pairs[] = $code . ':' . (int) $c;
            }
            $parts[] = sprintf(__('Critical: %s', 'wp-seo-next'), implode(', ', array_slice($pairs, 0, 10)));
        }
        if (!empty($warnHit)) {
            $pairs = [];
            foreach ($warnHit as $code => $c) {
                $pairs[] = $code . ':' . (int) $c;
            }
            $parts[] = sprintf(__('Warnings: %s', 'wp-seo-next'), implode(', ', array_slice($pairs, 0, 10)));
        }

        $message = implode(' · ', $parts);

        $url = admin_url('tools.php?page=wpsn-audit');

        $meta = [
            'scan_id' => (int) $scanId,
            'issues_total' => (int) $total,
            'critical_hits' => $critHit,
            'warn_hits' => $warnHit,
        ];

        $this->createEvent($eventKey, 'audit', 'audit_scan', $severity, $title, $message, $url, $meta);
    }

    /**
     * Source: Quality Gate run completed.
     *
     * @param array<string,mixed> $results
     * @param array<string,mixed> $context
     */
    public function onQualityGateRunCompleted(array $results, array $context = []): void
    {
        if (!$this->sourceEnabled('quality_gate', false)) {
            return;
        }

        $sev = isset($results['severity']) ? sanitize_key((string) $results['severity']) : 'info';
        if ($sev === 'warning') {
            $sev = 'warn';
        }
        if (!in_array($sev, ['ok', 'info', 'warn', 'critical'], true)) {
            $sev = 'info';
        }

        $bad = in_array($sev, ['warn', 'critical'], true);

        $lastStatusKey = 'wpsn_quality_gate_notify_last_status';
        $lastSigKey = 'wpsn_quality_gate_notify_last_sig';
        $episodeKey = 'wpsn_quality_gate_notify_episode';

        $lastStatus = sanitize_key((string) get_option($lastStatusKey, 'ok'));
        if (!in_array($lastStatus, ['ok', 'bad'], true)) {
            $lastStatus = 'ok';
        }

        if (!$bad) {
            // Transition back to OK clears the signature, so the next regression can notify again.
            update_option($lastStatusKey, 'ok', false);
            update_option($lastSigKey, '', false);
            return;
        }

        // Compute failing checks.
        $checks = isset($results['checks']) && is_array($results['checks']) ? $results['checks'] : [];
        $badChecks = [];
        $sigParts = [];
        foreach ($checks as $k => $c) {
            if (!is_array($c)) {
                continue;
            }
            $s = isset($c['severity']) ? sanitize_key((string) $c['severity']) : 'info';
            if ($s === 'warning') {
                $s = 'warn';
            }
            if (!in_array($s, ['ok', 'info', 'warn', 'critical'], true)) {
                $s = 'info';
            }
            if (!in_array($s, ['warn', 'critical'], true)) {
                continue;
            }

            $summary = isset($c['summary']) ? (string) $c['summary'] : '';
            $badChecks[$k] = [
                'severity' => $s,
                'summary' => $summary,
            ];
            $sigParts[] = sanitize_key((string) $k) . ':' . $s;
        }

        if (empty($badChecks)) {
            // If overall severity is bad but no per-check details exist, still avoid noise.
            update_option($lastStatusKey, 'ok', false);
            update_option($lastSigKey, '', false);
            return;
        }

        sort($sigParts);
        $sigBase = implode('|', $sigParts);
        $hash = substr(md5($sigBase), 0, 12);

        $episode = (int) get_option($episodeKey, 0);
        if ($lastStatus !== 'bad') {
            $episode = max(0, $episode) + 1;
            update_option($episodeKey, $episode, false);
            update_option($lastStatusKey, 'bad', false);
        } else {
            $episode = max(1, $episode);
        }

        $sig = 'ep' . $episode . ':' . $hash;
        $prevSig = (string) get_option($lastSigKey, '');
        if ($prevSig === $sig) {
            return;
        }
        update_option($lastSigKey, $sig, false);

        $labels = [
            'sitemap' => __('Sitemap', 'wp-seo-next'),
            'robots' => __('Robots', 'wp-seo-next'),
            'head' => __('Head output', 'wp-seo-next'),
            'queue' => __('Queue', 'wp-seo-next'),
        ];

        $title = $sev === 'critical'
            ? __('Quality Gate: CRITICAL issues detected', 'wp-seo-next')
            : __('Quality Gate: warnings detected', 'wp-seo-next');

        $parts = [];
        foreach ($badChecks as $k => $c) {
            $lbl = isset($labels[$k]) ? (string) $labels[$k] : sanitize_key((string) $k);
            $s = isset($c['severity']) ? sanitize_key((string) $c['severity']) : 'warn';
            $summary = isset($c['summary']) ? sanitize_text_field((string) $c['summary']) : '';
            $parts[] = strtoupper($s) . ' ' . $lbl . ': ' . ($summary !== '' ? $summary : __('Issue detected.', 'wp-seo-next'));
        }

        $message = implode(' · ', array_slice($parts, 0, 6));

        $url = admin_url('tools.php?page=wpsn-quality-gate');

        $meta = [
            'episode' => $episode,
            'sig_base' => $sigBase,
            'sig_hash' => $hash,
            'context' => $context,
            'bad_checks' => $badChecks,
        ];

        $eventKey = 'quality_gate:' . $sig;

        // If queue is in manual mode, avoid enqueuing send jobs; keep it in inbox only.
        $queueMode = (string) $this->settings()->get('performance.queue.mode', 'auto');
        $deliver = ($queueMode !== 'manual');

        $this->createEvent($eventKey, 'quality_gate', 'quality_gate', $sev, $title, $message, $url, $meta, $deliver);
    }

    /**
     * Source: scheduled Platform Ops probe found new backend regressions.
     *
     * We only notify on the cron-driven probe and only when something flipped
     * from OK -> WARN/CRITICAL between the previous and current snapshot.
     * Manual page runs still save the report, but do not create inbox noise.
     *
     * @param array<string,mixed> $report
     * @param array<string,mixed> $context
     */
    public function onPlatformOpsRunCompleted(array $report, array $context = []): void
    {
        if (!$this->sourceEnabled('platform_ops', false)) {
            return;
        }

        $trigger = isset($context['trigger']) ? sanitize_key((string) $context['trigger']) : '';
        if ($trigger !== 'cron') {
            return;
        }

        $previous = isset($context['previous']) && is_array($context['previous']) ? $context['previous'] : [];
        if (empty($previous) || empty($previous['id']) || empty($report['id'])) {
            return;
        }

        $diff = (new PlatformOpsDiffBuilder())->compare($previous, $report);
        $checkDiffs = isset($diff['checks']) && is_array($diff['checks']) ? $diff['checks'] : [];

        $flips = [];
        $highestSeverity = 'warn';
        foreach ($checkDiffs as $key => $row) {
            if (!is_array($row)) {
                continue;
            }

            $baseline = isset($row['baseline']) && is_array($row['baseline']) ? $row['baseline'] : [];
            $candidate = isset($row['candidate']) && is_array($row['candidate']) ? $row['candidate'] : [];
            $baseSeverity = $this->normalizeOperationalSeverity(isset($baseline['severity']) ? (string) $baseline['severity'] : 'info');
            $candidateSeverity = $this->normalizeOperationalSeverity(isset($candidate['severity']) ? (string) $candidate['severity'] : 'info');
            if ($baseSeverity !== 'ok' || !in_array($candidateSeverity, ['warn', 'critical'], true)) {
                continue;
            }

            $label = isset($row['label']) ? sanitize_text_field((string) $row['label']) : sanitize_key((string) $key);
            $summary = isset($candidate['summary']) ? sanitize_text_field((string) $candidate['summary']) : '';
            $flips[] = [
                'key' => sanitize_key((string) $key),
                'label' => $label,
                'from' => $baseSeverity,
                'to' => $candidateSeverity,
                'summary' => $summary,
            ];

            if ($candidateSeverity === 'critical') {
                $highestSeverity = 'critical';
            }
        }

        $prevOverall = $this->normalizeOperationalSeverity(isset($previous['severity']) ? (string) $previous['severity'] : 'info');
        $currentOverall = $this->normalizeOperationalSeverity(isset($report['severity']) ? (string) $report['severity'] : 'info');
        $overallFlip = ($prevOverall === 'ok' && in_array($currentOverall, ['warn', 'critical'], true));

        if (empty($flips) && !$overallFlip) {
            return;
        }

        if ($currentOverall === 'critical') {
            $highestSeverity = 'critical';
        }

        $title = $highestSeverity === 'critical'
            ? __('Platform backend regression detected', 'wp-seo-next')
            : __('Platform backend warning detected', 'wp-seo-next');

        $parts = [];
        foreach (array_slice($flips, 0, 5) as $flip) {
            $label = isset($flip['label']) ? (string) $flip['label'] : __('Backend', 'wp-seo-next');
            $to = isset($flip['to']) ? strtoupper((string) $flip['to']) : 'WARN';
            $summary = isset($flip['summary']) ? (string) $flip['summary'] : '';
            $parts[] = $label . ': OK → ' . $to . ($summary !== '' ? ' (' . $summary . ')' : '');
        }

        if ($overallFlip) {
            array_unshift($parts, sprintf(
                __('Overall platform health: %1$s → %2$s', 'wp-seo-next'),
                strtoupper($prevOverall),
                strtoupper($currentOverall)
            ));
        }

        $message = implode(' · ', $parts);
        if ($message === '') {
            $message = __('A scheduled platform probe detected a new backend regression.', 'wp-seo-next');
        }

        $url = admin_url('tools.php?page=wpsn-platform-ops');
        $eventKey = 'platform_ops_flip:' . sanitize_text_field((string) $report['id']);

        $previewChecks = [];
        foreach (array_slice($flips, 0, 3) as $flip) {
            if (!is_array($flip)) {
                continue;
            }
            $label = sanitize_text_field((string) ($flip['label'] ?? ''));
            $to = strtoupper(sanitize_key((string) ($flip['to'] ?? 'warn')));
            if ($label !== '') {
                $previewChecks[] = $label . ' → ' . $to;
            }
        }

        $meta = [
            'previous_run_id' => sanitize_text_field((string) ($previous['id'] ?? '')),
            'candidate_run_id' => sanitize_text_field((string) ($report['id'] ?? '')),
            'overall_flip' => $overallFlip,
            'previous_overall' => $prevOverall,
            'current_overall' => $currentOverall,
            'flips' => $flips,
            'diff_summary' => isset($diff['summary']) && is_array($diff['summary']) ? $diff['summary'] : [],
            'preview' => [
                'overall' => $overallFlip ? sprintf(
                    __('Overall: %1$s → %2$s', 'wp-seo-next'),
                    strtoupper($prevOverall),
                    strtoupper($currentOverall)
                ) : '',
                'checks' => $previewChecks,
                'flip_count' => count($flips),
            ],
            'context' => $context,
        ];

        $queueMode = (string) $this->settings()->get('performance.queue.mode', 'auto');
        $deliver = ($queueMode !== 'manual');

        $this->createEvent($eventKey, 'platform_ops', 'platform_ops_flip', $highestSeverity, $title, $message, $url, $meta, $deliver);
    }

    /**
     * Source: SaaS sync failures.
     */
    public function onSaaSSyncFailed(string $error, int $retryAfter): void
    {
        if (!$this->sourceEnabled('saas_sync_failed', true)) {
            return;
        }

        $days = (int) $this->settings()->get('notifications.default_mute_days', 7);
        $days = max(1, min(365, $days));

        // Dedupe by day to avoid spamming repeated sync errors.
        $eventKey = 'saas_sync_failed:' . gmdate('Y-m-d');

        $title = __('SaaS Sync failed', 'wp-seo-next');
        $msg = sanitize_text_field($error !== '' ? $error : __('Unknown error', 'wp-seo-next'));
        if ($retryAfter > 0) {
            $msg .= ' ' . sprintf(__('Retry after: %d seconds', 'wp-seo-next'), (int) $retryAfter);
        }

        $url = admin_url('tools.php?page=wpsn-saas-sync');

        $eventId = $this->createEvent($eventKey, 'saas_sync', 'saas_sync', 'warn', $title, $msg, $url, [
            'retry_after' => (int) $retryAfter,
        ]);

        // Auto-mute this daily event key by updating the record's muted_until.
        if ($eventId > 0) {
            try {
                $this->repo->mute([$eventId], $days);
            } catch (\Throwable $e) {
                // ignore
            }
        }
    }

    /**
     * Source: SaaS Dashboard connector failures.
     *
     * @param string $phase  push_health|pull_tasks|ack_task
     * @param string $error
     * @param array<string,mixed> $context
     */
    public function onSaaSDashboardFailed(string $phase, string $error, array $context = []): void
    {
        if (!$this->sourceEnabled('saas_sync_failed', true)) {
            // Reuse the same source toggle (operational sync failures).
            return;
        }

        $days = (int) $this->settings()->get('notifications.default_mute_days', 7);
        $days = max(1, min(365, $days));

        $phase = sanitize_key($phase);
        if ($phase === '') {
            $phase = 'sync';
        }

        // Dedupe by day per phase.
        $eventKey = 'saas_dashboard_failed:' . $phase . ':' . gmdate('Y-m-d');

        $title = __('SaaS Dashboard sync failed', 'wp-seo-next');
        $msg = sanitize_text_field($error !== '' ? $error : __('Unknown error', 'wp-seo-next'));
        $msg .= ' · ' . sprintf(__('Phase: %s', 'wp-seo-next'), $phase);

        $url = admin_url('tools.php?page=wpsn-saas-dashboard');

        $meta = [
            'phase' => $phase,
        ];

        // Keep meta small; avoid dumping large bodies.
        if (isset($context['code'])) {
            $meta['code'] = (int) $context['code'];
        }

        $eventId = $this->createEvent($eventKey, 'saas_dashboard', 'saas_dashboard', 'warn', $title, $msg, $url, $meta);

        if ($eventId > 0) {
            try {
                $this->repo->mute([$eventId], $days);
            } catch (\Throwable $e) {
                // ignore
            }
        }
    }

    /**
     * Source: SERP gap run failed.
     */
    public function onSerpGapFailed(int $runId, string $query, string $error): void
    {
        if (!$this->sourceEnabled('serp_gap_failed', false)) {
            return;
        }

        $eventKey = 'serp_gap_failed:' . max(0, $runId);

        $title = __('SERP Gap run failed', 'wp-seo-next');
        $msg = $error !== '' ? sanitize_text_field($error) : __('Unknown error', 'wp-seo-next');

        $url = admin_url('tools.php?page=wpsn-serp-gap');

        $this->createEvent($eventKey, 'serp_gap', 'serp_gap', 'warn', $title, $msg, $url, [
            'run_id' => (int) $runId,
            'query' => sanitize_text_field($query),
        ]);
    }

    /**
     * Manual test event (from UI).
     */
    public function createTestEvent(): void
    {
        $eventKey = 'test:' . gmdate('YmdHis') . ':' . wp_generate_uuid4();
        $title = __('Test notification', 'wp-seo-next');
        $msg = __('This is a test notification from WP SEO Next.', 'wp-seo-next');

        $this->createEvent($eventKey, 'test', 'test', 'info', $title, $msg, admin_url('tools.php?page=wpsn-notifications'), [
            'user_id' => (int) get_current_user_id(),
        ]);
    }


    /**
     * Cron tick: enqueue daily/weekly digest jobs if due.
     *
     * This is intentionally lightweight and only enqueues queue jobs.
     */
    public function onDigestTick(): void
    {
        if (!$this->enabled()) {
            return;
        }

        $queueMode = (string) $this->settings()->get('performance.queue.mode', 'auto');
        if ($queueMode === 'manual') {
            return; // avoid accumulating digest jobs
        }

        $dig = $this->settings()->get('notifications.digests', []);
        if (!is_array($dig)) {
            $dig = [];
        }

        $last = get_option('wpsn_notifications_digest_last_sent', []);
        if (!is_array($last)) {
            $last = [];
        }

        $hourNow = (int) gmdate('G');
        $wdayNow = (int) gmdate('N'); // 1..7
        $today = (string) gmdate('Y-m-d');
        $weekKey = (string) gmdate('o-W');

        // Daily.
        if (!empty($dig['daily_enabled'])) {
            $hour = isset($dig['daily_hour_utc']) ? (int) $dig['daily_hour_utc'] : 9;
            $hour = max(0, min(23, $hour));

            if ($hourNow >= $hour) {
                $already = isset($last['daily']) && (string) $last['daily'] === $today;
                $enqKey = 'wpsn_notifications_digest_enq_daily_' . $today;

                if (!$already && !get_transient($enqKey)) {
                    try {
                        $this->queue()->enqueue(self::JOB_SEND_DIGEST, [
                            'period' => 'daily',
                            'key' => $today,
                        ]);
                        set_transient($enqKey, 1, 2 * DAY_IN_SECONDS);
                    } catch (\Throwable $e) {
                        // ignore
                    }
                }
            }
        }

        // Weekly.
        if (!empty($dig['weekly_enabled'])) {
            $hour = isset($dig['weekly_hour_utc']) ? (int) $dig['weekly_hour_utc'] : 9;
            $hour = max(0, min(23, $hour));

            $wday = isset($dig['weekly_weekday_utc']) ? (int) $dig['weekly_weekday_utc'] : 1;
            $wday = max(1, min(7, $wday));

            if ($wdayNow === $wday && $hourNow >= $hour) {
                $already = isset($last['weekly']) && (string) $last['weekly'] === $weekKey;
                $enqKey = 'wpsn_notifications_digest_enq_weekly_' . $weekKey;

                if (!$already && !get_transient($enqKey)) {
                    try {
                        $this->queue()->enqueue(self::JOB_SEND_DIGEST, [
                            'period' => 'weekly',
                            'key' => $weekKey,
                        ]);
                        set_transient($enqKey, 1, 14 * DAY_IN_SECONDS);
                    } catch (\Throwable $e) {
                        // ignore
                    }
                }
            }
        }
    }

    /**
     * Queue handler: send a digest (daily / weekly).
     *
     * @param mixed $null
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return array<string,mixed>|null
     */
    public function handleSendDigest($null, array $payload, array $job): ?array
    {
        if (!$this->enabled()) {
            return null;
        }

        $dig = $this->settings()->get('notifications.digests', []);
        if (!is_array($dig)) {
            $dig = [];
        }

        $period = isset($payload['period']) ? sanitize_key((string) $payload['period']) : '';
        if (!in_array($period, ['daily', 'weekly'], true)) {
            return null;
        }

        $enabledKey = $period . '_enabled';
        if (empty($dig[$enabledKey])) {
            return null;
        }

        $minSev = isset($dig['min_severity']) ? sanitize_key((string) $dig['min_severity']) : 'warn';
        if (!in_array($minSev, ['info', 'warn', 'critical'], true)) {
            $minSev = 'warn';
        }

        $maxItems = isset($dig['max_items']) ? (int) $dig['max_items'] : 25;
        $maxItems = max(1, min(200, $maxItems));

        $sendEmpty = !empty($dig['send_when_empty']);

        // Collect open notifications (not acked, not muted).
        $items = $this->repo->listOpenForDigest($minSev, $maxItems);

        if (empty($items) && !$sendEmpty) {
            // Still mark as sent to avoid repeated "empty digests" in the same period.
            $last = get_option('wpsn_notifications_digest_last_sent', []);
            if (!is_array($last)) {
                $last = [];
            }
            $last[$period] = isset($payload['key']) ? sanitize_text_field((string) $payload['key']) : gmdate('Y-m-d');
            update_option('wpsn_notifications_digest_last_sent', $last, false);
            return null;
        }

        $cfg = $this->settings()->get('notifications', []);
        if (!is_array($cfg)) {
            $cfg = [];
        }
        $channels = isset($cfg['channels']) && is_array($cfg['channels']) ? $cfg['channels'] : [];

        $emailEnabled = !empty($channels['email_enabled']);
        $emailRecipients = isset($channels['email_recipients']) ? (string) $channels['email_recipients'] : '';

        $slackEnabled = !empty($channels['slack_enabled']);
        $slackWebhook = isset($channels['slack_webhook']) ? (string) $channels['slack_webhook'] : '';

        $webhookEnabled = !empty($channels['webhook_enabled']);
        $webhookUrls = isset($channels['webhook_urls']) ? (string) $channels['webhook_urls'] : '';

        $localOnly = (bool) $this->settings()->get('privacy.local_only', false);

        // Digest "severity" (best-effort) for rate limiting.
        $digestSev = 'info';
        foreach ($items as $it) {
            $s = isset($it['severity']) ? sanitize_key((string) $it['severity']) : 'info';
            if ($this->severityRank($s) > $this->severityRank($digestSev)) {
                $digestSev = $s;
            }
        }

        $wait = $this->checkSendBudget($digestSev);
        if ($wait > 0) {
            return [
                'status' => 'retry',
                'retry_in' => $wait,
                'error' => 'send_budget_exceeded',
            ];
        }

        $inboxUrl = admin_url('tools.php?page=wpsn-notifications&tab=inbox&view=open');

        $title = $period === 'weekly'
            ? __('Weekly notifications digest', 'wp-seo-next')
            : __('Daily notifications digest', 'wp-seo-next');

        $count = count($items);
        $generatedAt = gmdate('Y-m-d H:i:s') . ' UTC';

        $lines = [];
        foreach ($items as $it) {
            $sev = isset($it['severity']) ? strtoupper(sanitize_key((string) $it['severity'])) : 'INFO';
            $src = isset($it['source']) ? sanitize_key((string) $it['source']) : '';
            $t = isset($it['title']) ? (string) $it['title'] : '';
            $url = isset($it['url']) ? (string) $it['url'] : '';
            $created = isset($it['created_at']) ? (string) $it['created_at'] : '';

            $row = "- [{$sev}] {$src} — {$t}";
            if ($created !== '') {
                $row .= " ({$created})";
            }
            if ($url !== '') {
                $row .= " — {$url}";
            }
            $lines[] = $row;
        }

        if (empty($lines)) {
            $lines[] = __('(No open items)', 'wp-seo-next');
        }

        $sentAny = false;
        $errors = [];

        // Email.
        if ($emailEnabled) {
            $to = $this->parseRecipients($emailRecipients);
            if (empty($to)) {
                $to = [get_option('admin_email')];
            }

            $subject = sprintf('[WP SEO Next] %s: %d open item(s)', $period === 'weekly' ? 'WEEKLY DIGEST' : 'DAILY DIGEST', $count);

            $bodyLines = [
                $title,
                'Generated: ' . $generatedAt,
                'Open items (shown): ' . $count,
                '',
                'Inbox: ' . $inboxUrl,
                '',
                implode("\n", $lines),
                '',
                'Tip: Ack/Mute items in the Inbox to reduce noise.',
            ];
            $body = implode("\n", $bodyLines);

            $ok = wp_mail($to, $subject, $body);
            if ($ok) {
                $sentAny = true;
            } else {
                $errors[] = 'email_failed';
            }
        }

        // Slack.
        if ($slackEnabled && !$localOnly) {
            $slackWebhook = trim($slackWebhook);
            if ($slackWebhook !== '') {
                $text = '*WP SEO Next* ' . ($period === 'weekly' ? 'WEEKLY DIGEST' : 'DAILY DIGEST') . " ({$count} open)\n" . implode("\n", array_slice($lines, 0, 50));
                $text .= "\n" . $inboxUrl;

                $resp = HttpClient::post($slackWebhook, [
                    'timeout' => 8,
                    'headers' => [
                        'Content-Type' => 'application/json; charset=utf-8',
                        'X-WPSN-Service' => 'notifications',
                        'X-WPSN-Context' => 'slack_digest',
                    ],
                    'body' => wp_json_encode(['text' => $text]),
                ], [
                    'service' => 'notifications',
                    'context' => 'slack_digest',
                    // Avoid duplicates.
                    'retries' => 0,
                ]);

                if (is_wp_error($resp)) {
                    $errors[] = 'slack_error:' . $resp->get_error_message();
                } else {
                    $code = (int) wp_remote_retrieve_response_code($resp);
                    if ($code >= 200 && $code < 300) {
                        $sentAny = true;
                    } else {
                        $errors[] = 'slack_http:' . $code;
                    }
                }
            }
        } elseif ($slackEnabled && $localOnly) {
            $errors[] = 'slack_blocked_local_only';
        }

        // Webhooks.
        if ($webhookEnabled && !$localOnly) {
            $urls = $this->parseWebhookUrls($webhookUrls);
            foreach ($urls as $wh) {
                $payloadOut = [
                    'kind' => 'wpsn_notifications_digest',
                    'period' => $period,
                    'generated_at' => $generatedAt,
                    'open_count' => $count,
                    'min_severity' => $minSev,
                    'inbox_url' => $inboxUrl,
                    'items' => array_map(static function ($it) {
                        return [
                            'id' => isset($it['id']) ? (int) $it['id'] : 0,
                            'created_at' => isset($it['created_at']) ? (string) $it['created_at'] : '',
                            'source' => isset($it['source']) ? (string) $it['source'] : '',
                            'severity' => isset($it['severity']) ? (string) $it['severity'] : '',
                            'title' => isset($it['title']) ? (string) $it['title'] : '',
                            'message' => isset($it['message']) ? (string) $it['message'] : '',
                            'url' => isset($it['url']) ? (string) $it['url'] : '',
                        ];
                    }, $items),
                ];

                $resp = HttpClient::post($wh, [
                    'timeout' => 8,
                    'headers' => [
                        'Content-Type' => 'application/json; charset=utf-8',
                        'X-WPSN-Service' => 'notifications',
                        'X-WPSN-Context' => 'webhook_digest',
                    ],
                    'body' => wp_json_encode($payloadOut),
                ], [
                    'service' => 'notifications',
                    'context' => 'webhook_digest',
                    'retries' => 0,
                ]);

                if (is_wp_error($resp)) {
                    $errors[] = 'webhook_error:' . $resp->get_error_message();
                    continue;
                }

                $code = (int) wp_remote_retrieve_response_code($resp);
                if ($code >= 200 && $code < 300) {
                    $sentAny = true;
                } else {
                    $errors[] = 'webhook_http:' . $code;
                }
            }
        } elseif ($webhookEnabled && $localOnly) {
            $errors[] = 'webhook_blocked_local_only';
        }

        // Mark last sent if at least one channel succeeded, or when no channels are enabled.
        if ($sentAny || (!$emailEnabled && !$slackEnabled && !$webhookEnabled)) {
            if ($sentAny) {
                $this->bumpSendBudget();
            }

            $key = isset($payload['key']) ? sanitize_text_field((string) $payload['key']) : ($period === 'weekly' ? gmdate('o-W') : gmdate('Y-m-d'));

            $last = get_option('wpsn_notifications_digest_last_sent', []);
            if (!is_array($last)) {
                $last = [];
            }
            $last[$period] = $key;
            update_option('wpsn_notifications_digest_last_sent', $last, false);

            return null;
        }

        // No channel succeeded.
        $attempts = isset($job['attempts']) ? (int) $job['attempts'] : 1;
        if ($attempts < 4) {
            $errStr = !empty($errors) ? implode('; ', array_slice($errors, 0, 6)) : 'digest_failed';
            return [
                'status' => 'retry',
                'retry_in' => 600,
                'error' => $errStr,
            ];
        }

        $errStr = !empty($errors) ? implode('; ', array_slice($errors, 0, 6)) : 'digest_failed';
        return [
            'status' => 'failed',
            'error' => $errStr,
        ];
    }

    /**
     * Queue handler: send an event.
     *
     * @param mixed $null
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return array<string,mixed>|null
     */
    public function handleSend($null, array $payload, array $job): ?array
    {
        $eventId = isset($payload['event_id']) ? (int) $payload['event_id'] : 0;
        if ($eventId <= 0) {
            return null;
        }

        $event = $this->repo->get($eventId);
        if (!is_array($event)) {
            return null;
        }

        if (!empty($event['sent_at'])) {
            return null; // already sent
        }

        $attempts = (int) ($event['send_attempts'] ?? 0) + 1;

        $cfg = $this->settings()->get('notifications', []);
        if (!is_array($cfg)) {
            $cfg = [];
        }
        $channels = isset($cfg['channels']) && is_array($cfg['channels']) ? $cfg['channels'] : [];

        $emailEnabled = !empty($channels['email_enabled']);
        $emailRecipients = isset($channels['email_recipients']) ? (string) $channels['email_recipients'] : '';

        $slackEnabled = !empty($channels['slack_enabled']);
        $slackWebhook = isset($channels['slack_webhook']) ? (string) $channels['slack_webhook'] : '';

        $webhookEnabled = !empty($channels['webhook_enabled']);
        $webhookUrls = isset($channels['webhook_urls']) ? (string) $channels['webhook_urls'] : '';

        $localOnly = (bool) $this->settings()->get('privacy.local_only', false);

        $sentAny = false;
        $errors = [];

        $severity = isset($event['severity']) ? sanitize_key((string) $event['severity']) : 'info';

        $wait = $this->checkSendBudget($severity);
        if ($wait > 0) {
            return [
                'status' => 'retry',
                'retry_in' => $wait,
                'error' => 'send_budget_exceeded',
            ];
        }

        $title = isset($event['title']) ? (string) $event['title'] : '';
        $message = isset($event['message']) ? (string) $event['message'] : '';
        $url = isset($event['url']) ? (string) $event['url'] : '';

        $meta = isset($event['meta_decoded']) && is_array($event['meta_decoded']) ? $event['meta_decoded'] : [];

        // Email.
        if ($emailEnabled) {
            $to = $this->parseRecipients($emailRecipients);
            if (empty($to)) {
                $to = [get_option('admin_email')];
            }

            $subjectOverride = isset($meta['email_subject']) ? trim((string) $meta['email_subject']) : '';
            $subject = $subjectOverride !== ''
                ? $subjectOverride
                : sprintf('[WP SEO Next] %s: %s', strtoupper($severity), $title);

            $bodyOverride = isset($meta['email_body']) ? (string) $meta['email_body'] : '';

            if ($bodyOverride !== '') {
                $body = $bodyOverride;
                if ($url !== '' && strpos($bodyOverride, $url) === false) {
                    $body .= "\n\nLink: " . $url;
                }
            } else {
                $bodyLines = [
                    'WP SEO Next notification',
                    'Severity: ' . strtoupper($severity),
                    'Source: ' . (isset($event['source']) ? (string) $event['source'] : ''),
                    '',
                    $title,
                    $message,
                ];
                if ($url !== '') {
                    $bodyLines[] = '';
                    $bodyLines[] = 'Link: ' . $url;
                }
                $body = implode("\n", $bodyLines);
            }

            $ok = wp_mail($to, $subject, $body);
            if ($ok) {
                $sentAny = true;
            } else {
                $errors[] = 'email_failed';
            }
        }

        // Slack.
        if ($slackEnabled && !$localOnly) {
            $slackWebhook = trim($slackWebhook);
            if ($slackWebhook !== '') {
                $slackOverride = isset($meta['slack_text']) ? trim((string) $meta['slack_text']) : '';
                if ($slackOverride !== '') {
                    $text = $slackOverride;
                } else {
                    $text = '*WP SEO Next* ' . strtoupper($severity) . ": {$title}\n" . $message;
                    if ($url !== '') {
                        $text .= "\n" . $url;
                    }
                }
                if ($url !== '') {
                    // If override did not include the URL, append it.
                    if ($slackOverride !== '' && strpos($slackOverride, $url) === false) {
                        $text .= "\n" . $url;
                    }
                }

                $resp = HttpClient::post($slackWebhook, [
                    'timeout' => 8,
                    'headers' => [
                        'Content-Type' => 'application/json; charset=utf-8',
                        'X-WPSN-Service' => 'notifications',
                        'X-WPSN-Context' => 'slack',
                    ],
                    'body' => wp_json_encode(['text' => $text]),
                ], [
                    'service' => 'notifications',
                    'context' => 'slack',
                    'retries' => 0,
                ]);

                if (is_wp_error($resp)) {
                    $errors[] = 'slack_error:' . $resp->get_error_message();
                } else {
                    $code = (int) wp_remote_retrieve_response_code($resp);
                    if ($code >= 200 && $code < 300) {
                        $sentAny = true;
                    } else {
                        $errors[] = 'slack_http:' . $code;
                    }
                }
            }
        } elseif ($slackEnabled && $localOnly) {
            $errors[] = 'slack_blocked_local_only';
        }

        // Webhooks.
        if ($webhookEnabled && !$localOnly) {
            $urls = $this->parseWebhookUrls($webhookUrls);
            foreach ($urls as $wh) {
                $payloadOut = [
                    'event_id' => (int) $eventId,
                    'created_at' => isset($event['created_at']) ? (string) $event['created_at'] : '',
                    'source' => isset($event['source']) ? (string) $event['source'] : '',
                    'type' => isset($event['type']) ? (string) $event['type'] : '',
                    'severity' => $severity,
                    'title' => $title,
                    'message' => $message,
                    'url' => $url,
                    'meta' => isset($event['meta_decoded']) && is_array($event['meta_decoded']) ? $event['meta_decoded'] : [],
                ];

                $resp = HttpClient::post($wh, [
                    'timeout' => 8,
                    'headers' => [
                        'Content-Type' => 'application/json; charset=utf-8',
                        'X-WPSN-Service' => 'notifications',
                        'X-WPSN-Context' => 'webhook',
                    ],
                    'body' => wp_json_encode($payloadOut),
                ], [
                    'service' => 'notifications',
                    'context' => 'webhook',
                    'retries' => 0,
                ]);

                if (is_wp_error($resp)) {
                    $errors[] = 'webhook_error:' . $resp->get_error_message();
                    continue;
                }

                $code = (int) wp_remote_retrieve_response_code($resp);
                if ($code >= 200 && $code < 300) {
                    $sentAny = true;
                } else {
                    $errors[] = 'webhook_http:' . $code;
                }
            }
        } elseif ($webhookEnabled && $localOnly) {
            $errors[] = 'webhook_blocked_local_only';
        }

        $errStr = '';
        if (!empty($errors)) {
            $errStr = implode('; ', array_slice($errors, 0, 6));
        }

        if ($sentAny) {
            $this->bumpSendBudget();
        }

        if ($sentAny || (!$emailEnabled && !$slackEnabled && !$webhookEnabled)) {
            $this->repo->markSent($eventId, $attempts, $errStr);
            return null;
        }

        // No channel succeeded.
        $this->repo->bumpAttempt($eventId, $attempts, $errStr);

        // Retry a few times.
        if ($attempts < 4) {
            return [
                'status' => 'retry',
                'retry_in' => 300,
                'error' => $errStr !== '' ? $errStr : 'notify_failed',
            ];
        }

        return [
            'status' => 'failed',
            'error' => $errStr !== '' ? $errStr : 'notify_failed',
        ];
    }

    /**
     * @return array<int,string>
     */
    private function parseRecipients(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }

        $parts = preg_split('/[\s,;]+/', $raw) ?: [];
        $out = [];
        foreach ($parts as $p) {
            $p = trim((string) $p);
            if ($p === '') {
                continue;
            }
            $p = sanitize_email($p);
            if ($p !== '' && is_email($p)) {
                $out[] = $p;
            }
        }

        $out = array_values(array_unique($out));
        return array_slice($out, 0, 30);
    }

    /**
     * @return array<int,string>
     */
    private function parseWebhookUrls(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }

        $lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
        $out = [];
        foreach ($lines as $ln) {
            $ln = trim((string) $ln);
            if ($ln === '') {
                continue;
            }
            $u = esc_url_raw($ln);
            if ($u === '') {
                continue;
            }
            // Only allow http(s)
            if (!preg_match('#^https?://#i', $u)) {
                continue;
            }
            $out[] = $u;
        }

        $out = array_values(array_unique($out));
        return array_slice($out, 0, 10);
    }
}
