<?php
/**
 * PK Core - the shared signal-alerting path.
 *
 * The estate measures deliverability and engagement across five sending sites
 * in some detail and, until this module, could not tell anyone anything. A
 * DKIM alignment fault ran about eight months and a newsletter traffic
 * collapse ran four weeks; both were found by a human noticing a number
 * looked odd. The data was already there. Nothing looked at it and said so.
 *
 * This is the thing that says so. Every detection rule in every PublisherKits
 * plugin calls one function:
 *
 *     pk_signal_alert( array(
 *         'code'     => 'pes.conversion_collapse',
 *         'severity' => 'critical',
 *         'site'     => 'example.com',
 *         'title'    => 'Newsletter click-to-session conversion collapsed',
 *         'detail'   => '1.59 per 1k delivered vs 15.78 14-send median',
 *         'context'  => array( 'send' => 'newsletter-2026-06-26' ),
 *     ) );
 *
 * DELIBERATELY NOT A DASHBOARD. The failure mode being corrected is nobody
 * looking at dashboards, so the only outputs are email and an audit table.
 *
 * @package PublisherKits\Core
 * @since   0.9.0
 */

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

if ( ! class_exists( 'PKCORE_Alerts' ) ) {

	/**
	 * Class PKCORE_Alerts
	 *
	 * @since 0.9.0
	 */
	class PKCORE_Alerts {

		/** Option: comma-separated recipient list. Empty means admin email. */
		const OPT_RECIPIENTS = 'pkcore_alert_recipients';

		/** Option: minimum delivered volume before a send-scoped rule may fire. */
		const OPT_VOLUME_FLOOR = 'pkcore_alert_volume_floor';

		/** Option: hours between repeat notifications for one group per site. */
		const OPT_COOLDOWN_HOURS = 'pkcore_alert_cooldown_hours';

		/** Option: master send switch. Alerts are still recorded when off. */
		const OPT_ENABLED = 'pkcore_alert_emails_enabled';

		/** Cron hook for the daily warning/info digest. */
		const CRON_DIGEST = 'pkcore_alerts_daily_digest';

		/** Severities, most severe first. */
		const SEVERITIES = array( 'critical', 'warning', 'info' );

		/**
		 * Buffered critical alerts for the open batch: group => row payloads.
		 *
		 * @var array<string,array[]>
		 */
		private static $batch = array();

		/**
		 * Whether a batch is currently open.
		 *
		 * @var bool
		 */
		private static $batching = false;

		/**
		 * Hook the digest cron. Called once by PKCORE_Core when notify is active.
		 *
		 * @return void
		 */
		public static function init() {
			add_action( self::CRON_DIGEST, array( __CLASS__, 'run_digest' ) );

			if ( ! wp_next_scheduled( self::CRON_DIGEST ) ) {
				// 07:00 UTC: after the ES pipeline's 02:00 pass and the rule
				// run that follows it, so the morning digest describes the
				// night that has just been assessed rather than the one before.
				wp_schedule_event( self::next_utc( 7 ), 'daily', self::CRON_DIGEST );
			}

			// A batch left open by a fatal mid-run would strand its rows at
			// 'pending' and swallow the alert. Flush on shutdown as well.
			add_action( 'shutdown', array( __CLASS__, 'flush_batch' ), 99 );
		}

		// ====== The public contract ======

		/**
		 * Raise a signal alert. Records it, then decides whether a human hears.
		 *
		 * EVERY call writes a row, including calls that are suppressed. The
		 * suppression reason is recorded with it. "Why did nothing warn us?"
		 * must be answerable from the table alone, months later.
		 *
		 * Recognised $args keys:
		 *   - code     (string, required) Stable, greppable identifier.
		 *   - severity (string) 'critical' | 'warning' | 'info'. Default 'info'.
		 *   - site     (string) Site the signal concerns. Defaults to this host.
		 *   - title    (string, required) One-line human summary.
		 *   - detail   (string) The numbers, in prose.
		 *   - context  (array)  Structured numbers for the email and the log.
		 *   - group    (string) Correlated rules sharing one notification and one
		 *              cool-down. Defaults to $code.
		 *   - volume   (int)    Delivered volume, for the minimum-volume guard.
		 *              Omit for rules that are not send-scoped.
		 *
		 * @since 0.9.0
		 * @param array $args The alert definition, keyed as described above.
		 * @return int Row id in the alerts table, or 0 if the call was invalid.
		 */
		public static function raise( array $args ) {
			$code  = isset( $args['code'] ) ? self::sanitize_code( $args['code'] ) : '';
			$title = isset( $args['title'] ) ? sanitize_text_field( (string) $args['title'] ) : '';

			// A code and a title are the minimum that makes a row meaningful.
			// Reject rather than record noise, and say so in the log so a
			// miswired rule is findable.
			if ( '' === $code || '' === $title ) {
				self::log( 'pk_signal_alert() called without a code or a title; ignored.' );
				return 0;
			}

			$severity = isset( $args['severity'] ) ? strtolower( (string) $args['severity'] ) : 'info';
			if ( ! in_array( $severity, self::SEVERITIES, true ) ) {
				$severity = 'info';
			}

			$group = ! empty( $args['group'] ) ? self::sanitize_code( $args['group'] ) : $code;
			$site  = ! empty( $args['site'] ) ? sanitize_text_field( (string) $args['site'] ) : self::default_site();

			$row = array(
				'code'              => $code,
				'alert_group'       => $group,
				'severity'          => $severity,
				'site'              => substr( $site, 0, 190 ),
				'title'             => substr( $title, 0, 255 ),
				'detail'            => isset( $args['detail'] ) ? sanitize_textarea_field( (string) $args['detail'] ) : '',
				'context'           => wp_json_encode( isset( $args['context'] ) ? (array) $args['context'] : array() ),
				'status'            => 'pending',
				'suppressed_reason' => '',
				'recipients'        => '',
				'created_at'        => gmdate( 'Y-m-d H:i:s' ),
				'notified_at'       => null,
			);

			$reason = self::suppression_reason( $args, $group, $row['site'] );
			if ( '' !== $reason ) {
				$row['status']            = 'suppressed';
				$row['suppressed_reason'] = $reason;
				return self::insert( $row );
			}

			$recipients = self::recipients();
			if ( empty( $recipients ) ) {
				$row['status']            = 'suppressed';
				$row['suppressed_reason'] = 'no_recipients';
				self::log( 'Alert ' . $code . ' had no valid recipient address; recorded but not sent.' );
				return self::insert( $row );
			}

			$row['recipients'] = implode( ',', $recipients );

			// Severity routing. critical reaches a person now; warning and info
			// wait for the daily digest so routine noise cannot bury an outage.
			if ( 'critical' !== $severity ) {
				$row['status'] = 'queued';
				return self::insert( $row );
			}

			$id = self::insert( $row );
			if ( ! $id ) {
				return 0;
			}

			$row['id'] = $id;

			// Inside a batch, correlated criticals coalesce into one email at
			// flush. Rules 1 and 2 both fire on a broken click-to-page chain;
			// that is two true findings about one incident, and should read as
			// one message naming both signals.
			if ( self::$batching ) {
				self::$batch[ $group ][] = $row;
				return $id;
			}

			self::deliver( array( $row ), $recipients );
			return $id;
		}

		/**
		 * Open a coalescing batch. Criticals raised until flush_batch() are
		 * held and grouped. Idempotent.
		 *
		 * @since 0.9.0
		 * @return void
		 */
		public static function begin_batch() {
			self::$batching = true;
		}

		/**
		 * Send every buffered group as one email each, and close the batch.
		 * Safe to call when no batch is open.
		 *
		 * @since 0.9.0
		 * @return int Number of emails sent.
		 */
		public static function flush_batch() {
			if ( ! self::$batching ) {
				return 0;
			}

			$batch          = self::$batch;
			self::$batch    = array();
			self::$batching = false;

			$recipients = self::recipients();
			$sent       = 0;

			foreach ( $batch as $rows ) {
				if ( empty( $rows ) || empty( $recipients ) ) {
					continue;
				}
				if ( self::deliver( $rows, $recipients ) ) {
					++$sent;
				}
			}

			return $sent;
		}

		// ====== Guards ======

		/**
		 * Why this alert must not be sent, or '' if it may be.
		 *
		 * @param array  $args  Original call arguments.
		 * @param string $group Resolved group key.
		 * @param string $site  Resolved site.
		 * @return string One of '', 'disabled', 'below_volume_floor', 'cooldown'.
		 */
		private static function suppression_reason( array $args, $group, $site ) {
			if ( ! self::emails_enabled() ) {
				return 'disabled';
			}

			// Minimum-volume guard. Only applies when the caller declares a
			// volume: a send-scoped rule passes cached_delivered, so a small
			// list (a few hundred recipients) cannot generate noise. Rules
			// that are not per-send - DMARC alignment, ingestion staleness -
			// omit it and carry their own floors.
			$volume = null;
			if ( isset( $args['volume'] ) ) {
				$volume = (int) $args['volume'];
			} elseif ( isset( $args['context']['delivered'] ) ) {
				$volume = (int) $args['context']['delivered'];
			}

			if ( null !== $volume && $volume < self::volume_floor() ) {
				return 'below_volume_floor';
			}

			if ( self::in_cooldown( $group, $site ) ) {
				return 'cooldown';
			}

			return '';
		}

		/**
		 * Whether this group has already reached a human for this site inside
		 * the cool-down window.
		 *
		 * Counts both delivered alerts and ones queued for the next digest: a
		 * queued warning is already on its way to somebody, and should not be
		 * re-raised nightly for the four weeks an outage runs.
		 *
		 * @param string $group Group key.
		 * @param string $site  Site.
		 * @return bool
		 */
		private static function in_cooldown( $group, $site ) {
			$hours = self::cooldown_hours();
			if ( $hours <= 0 ) {
				return false;
			}

			global $wpdb;

			$table = PKCORE_Schema::table_alerts();
			$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table, and a cached answer would defeat the guard.
			$found = $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from a constant method, not input.
					"SELECT id FROM `{$table}`
					 WHERE alert_group = %s AND site = %s
					   AND status IN ('notified', 'queued')
					   AND COALESCE(notified_at, created_at) >= %s
					 LIMIT 1",
					$group,
					$site,
					$since
				)
			);
			// phpcs:enable

			return ! empty( $found );
		}

		// ====== Delivery ======

		/**
		 * Email one group's alerts as a single message and mark them notified.
		 *
		 * @param array[]  $rows       Alert rows (each may carry an 'id').
		 * @param string[] $recipients Validated addresses.
		 * @return bool Whether wp_mail accepted the message.
		 */
		private static function deliver( array $rows, array $recipients ) {
			if ( empty( $rows ) || empty( $recipients ) ) {
				return false;
			}

			$first    = $rows[0];
			$site     = $first['site'];
			$severity = strtoupper( $first['severity'] );

			$subject = sprintf( '[%s] %s - %s', $severity, $site, $first['title'] );
			if ( count( $rows ) > 1 ) {
				$subject = sprintf(
					/* translators: 1: severity, 2: site, 3: first alert title, 4: count of further alerts. */
					__( '[%1$s] %2$s - %3$s (+%4$d more)', 'publisherkits-socialpromoter' ),
					$severity,
					$site,
					$first['title'],
					count( $rows ) - 1
				);
			}

			$body = self::render_body( $rows );

			$sent = wp_mail( $recipients, $subject, $body );

			self::mark_notified( $rows, $sent, implode( ',', $recipients ) );

			return (bool) $sent;
		}

		/**
		 * Plain-text email body. Deliberately plain: it must be readable on a
		 * phone at 07:00 and quotable into a ticket without reformatting.
		 *
		 * @param array[] $rows Alert rows.
		 * @return string
		 */
		private static function render_body( array $rows ) {
			$out = array();

			if ( count( $rows ) > 1 ) {
				$out[] = sprintf(
					/* translators: %d: number of correlated signals. */
					__( '%d correlated signals fired together. They describe one incident.', 'publisherkits-socialpromoter' ),
					count( $rows )
				);
				$out[] = '';
			}

			foreach ( $rows as $i => $row ) {
				if ( $i > 0 ) {
					$out[] = str_repeat( '-', 60 );
				}
				$out[] = $row['title'];
				$out[] = '';
				if ( '' !== $row['detail'] ) {
					$out[] = $row['detail'];
					$out[] = '';
				}

				$context = json_decode( (string) $row['context'], true );
				if ( is_array( $context ) && ! empty( $context ) ) {
					foreach ( $context as $key => $value ) {
						if ( is_scalar( $value ) ) {
							$out[] = sprintf( '  %-28s %s', $key . ':', $value );
						}
					}
					$out[] = '';
				}

				$out[] = sprintf( '  %-28s %s', __( 'signal:', 'publisherkits-socialpromoter' ), $row['code'] );
				$out[] = sprintf( '  %-28s %s', __( 'severity:', 'publisherkits-socialpromoter' ), $row['severity'] );
				$out[] = sprintf( '  %-28s %s', __( 'site:', 'publisherkits-socialpromoter' ), $row['site'] );
				$out[] = sprintf( '  %-28s %s UTC', __( 'detected:', 'publisherkits-socialpromoter' ), $row['created_at'] );
				$out[] = '';
			}

			$out[] = str_repeat( '=', 60 );
			$out[] = __( 'Raised by PublisherKits signal alerting.', 'publisherkits-socialpromoter' );
			$out[] = sprintf(
				/* translators: %s: alerts table name. */
				__( 'Every alert, including suppressed ones, is recorded in %s.', 'publisherkits-socialpromoter' ),
				PKCORE_Schema::table_alerts()
			);

			return implode( "\n", $out );
		}

		/**
		 * Mark rows notified (or failed) after a delivery attempt.
		 *
		 * @param array[] $rows       Alert rows.
		 * @param bool    $sent       Whether wp_mail accepted the message.
		 * @param string  $recipients Comma-separated addresses actually used.
		 * @return void
		 */
		private static function mark_notified( array $rows, $sent, $recipients ) {
			global $wpdb;

			$table = PKCORE_Schema::table_alerts();

			foreach ( $rows as $row ) {
				if ( empty( $row['id'] ) ) {
					continue;
				}

				// A refused wp_mail is recorded as such rather than as
				// delivered. An alert that silently failed to send is the
				// same failure as no alert at all, and must be visible.
				$data = $sent
					? array(
						'status'      => 'notified',
						'notified_at' => gmdate( 'Y-m-d H:i:s' ),
						'recipients'  => $recipients,
					)
					: array(
						'status'            => 'suppressed',
						'suppressed_reason' => 'send_failed',
						'recipients'        => $recipients,
					);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table.
				$wpdb->update( $table, $data, array( 'id' => (int) $row['id'] ) );
			}

			if ( ! $sent ) {
				self::log( 'wp_mail refused an alert for ' . $rows[0]['code'] . '; recorded as send_failed.' );
			}
		}

		// ====== Daily digest ======

		/**
		 * Send the daily digest of queued warning/info alerts, and rescue any
		 * critical stranded 'pending' by a run that died before flushing.
		 *
		 * @since 0.9.0
		 * @return int Number of alerts included.
		 */
		public static function run_digest() {
			global $wpdb;

			$table = PKCORE_Schema::table_alerts();

			// Rescue first: a critical stuck at 'pending' for over an hour
			// means a rule run died mid-batch. It still needs to be sent.
			$stale = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table.
			$stranded = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM `{$table}` WHERE status = 'pending' AND created_at < %s ORDER BY id ASC LIMIT 50",
					$stale
				),
				ARRAY_A
			);

			$queued = $wpdb->get_results(
				"SELECT * FROM `{$table}` WHERE status = 'queued' ORDER BY
				 FIELD(severity, 'critical', 'warning', 'info'), id ASC LIMIT 200",
				ARRAY_A
			);
			// phpcs:enable

			$recipients = self::recipients();

			if ( ! empty( $stranded ) && ! empty( $recipients ) ) {
				self::log( count( $stranded ) . ' stranded critical alert(s) recovered by the digest sweep.' );
				foreach ( $stranded as $row ) {
					self::deliver( array( $row ), $recipients );
				}
			}

			if ( empty( $queued ) ) {
				return 0;
			}

			if ( empty( $recipients ) ) {
				self::log( 'Digest has ' . count( $queued ) . ' queued alert(s) but no valid recipient.' );
				return 0;
			}

			$site    = $queued[0]['site'];
			$subject = sprintf(
				/* translators: 1: site, 2: number of alerts. */
				__( '[DIGEST] %1$s - %2$d signal alerts', 'publisherkits-socialpromoter' ),
				$site,
				count( $queued )
			);

			$sent = wp_mail( $recipients, $subject, self::render_body( $queued ) );
			self::mark_notified( $queued, $sent, implode( ',', $recipients ) );

			return count( $queued );
		}

		// ====== Settings ======

		/**
		 * Validated recipient addresses, falling back to the site admin.
		 *
		 * The owner decision is explicit: the right people differ by title, so
		 * this is a configurable list and not a hardcoded admin address. The
		 * admin address is the floor, not the design.
		 *
		 * @since 0.9.0
		 * @return string[] Valid, de-duplicated email addresses.
		 */
		public static function recipients() {
			$configured = self::parse_recipients( (string) get_option( self::OPT_RECIPIENTS, '' ) );

			if ( empty( $configured ) ) {
				$admin = get_option( 'admin_email', '' );
				if ( is_email( $admin ) ) {
					$configured = array( $admin );
				}
			}

			/**
			 * Filter the addresses a signal alert is sent to.
			 *
			 * @since 0.9.0
			 * @param string[] $configured Validated addresses.
			 */
			$filtered = apply_filters( 'pkcore_alert_recipients', $configured );

			// Re-validate after the filter: a bad address from a third-party
			// callback would otherwise make wp_mail fail for everyone on the
			// list, turning one misconfiguration into total alert silence.
			return self::parse_recipients( implode( ',', (array) $filtered ) );
		}

		/**
		 * Split, trim, validate and de-duplicate a comma-separated list.
		 *
		 * Shared by the settings save and the read path so what is stored and
		 * what is used can never disagree.
		 *
		 * @since 0.9.0
		 * @param string $raw Raw comma-separated input.
		 * @return string[] Valid addresses, order preserved.
		 */
		public static function parse_recipients( $raw ) {
			$out  = array();
			$seen = array();

			// Semicolons are what people actually paste out of Outlook.
			$parts = preg_split( '/[,;\r\n]+/', (string) $raw );

			foreach ( (array) $parts as $part ) {
				$address = sanitize_email( trim( (string) $part ) );
				if ( '' === $address || ! is_email( $address ) ) {
					continue;
				}
				$key = strtolower( $address );
				if ( isset( $seen[ $key ] ) ) {
					continue;
				}
				$seen[ $key ] = true;
				$out[]        = $address;
			}

			return $out;
		}

		/**
		 * Addresses in a raw list that are not valid email addresses, so the
		 * settings screen can name them instead of silently dropping them.
		 *
		 * @since 0.9.0
		 * @param string $raw Raw comma-separated input.
		 * @return string[]
		 */
		public static function invalid_recipients( $raw ) {
			$bad   = array();
			$parts = preg_split( '/[,;\r\n]+/', (string) $raw );

			foreach ( (array) $parts as $part ) {
				$part = trim( (string) $part );
				if ( '' === $part ) {
					continue;
				}
				if ( ! is_email( sanitize_email( $part ) ) ) {
					$bad[] = $part;
				}
			}

			return $bad;
		}

		/**
		 * The minimum delivered volume a send-scoped rule may fire on.
		 *
		 * @since 0.9.0
		 * @return int
		 */
		public static function volume_floor() {
			$floor = (int) get_option( self::OPT_VOLUME_FLOOR, 1000 );
			return max( 0, $floor );
		}

		/**
		 * Hours between repeat notifications for one group per site.
		 *
		 * @since 0.9.0
		 * @return int
		 */
		public static function cooldown_hours() {
			$hours = get_option( self::OPT_COOLDOWN_HOURS, 48 );
			if ( '' === $hours || null === $hours || false === $hours ) {
				$hours = 48;
			}
			return max( 0, (int) $hours );
		}

		/**
		 * Whether alert emails may be sent. Alerts are recorded either way.
		 *
		 * @since 0.9.0
		 * @return bool
		 */
		public static function emails_enabled() {
			$enabled = get_option( self::OPT_ENABLED, '1' );
			return ( '0' !== (string) $enabled );
		}

		// ====== Internals ======

		/**
		 * Insert an alert row.
		 *
		 * @param array $row Column => value.
		 * @return int Insert id, or 0 on failure.
		 */
		private static function insert( array $row ) {
			global $wpdb;

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table.
			$ok = $wpdb->insert( PKCORE_Schema::table_alerts(), $row );

			if ( ! $ok ) {
				// The alert table is the audit trail; losing a write to it is
				// worth a log line even though the email may still go out.
				self::log( 'Failed to record alert ' . $row['code'] . ' in the alerts table.' );
				return 0;
			}

			return (int) $wpdb->insert_id;
		}

		/**
		 * Sanitise a signal code or group key, PRESERVING the dot and colon.
		 *
		 * Deliberately not sanitize_key(): that strips '.' and ':', which
		 * silently turned 'pes.scanner_load_buildup' into
		 * 'pesscanner_load_buildup' in the first live alerts. The whole point
		 * of the code is to be stable and greppable - 'pes.' namespaces the
		 * owning plugin, and ':' separates a group's stream qualifier
		 * ('pes.engagement_break:newsletter'). Losing them makes the audit
		 * table harder to read and the codes harder to grep, which is the
		 * opposite of what the contract promises.
		 *
		 * @since 0.9.1
		 * @param string $value Raw code or group key.
		 * @return string Lowercased, trimmed to 64 chars, [a-z0-9_.:-] only.
		 */
		public static function sanitize_code( $value ) {
			$value = strtolower( (string) $value );
			$value = preg_replace( '/[^a-z0-9_.:-]/', '', $value );
			return substr( (string) $value, 0, 64 );
		}

		/**
		 * The site this install speaks for, when a caller does not name one.
		 *
		 * @return string
		 */
		private static function default_site() {
			$host = wp_parse_url( home_url(), PHP_URL_HOST );
			return $host ? (string) $host : 'site';
		}

		/**
		 * Next occurrence of a UTC hour, as a timestamp.
		 *
		 * @param int $hour UTC hour (0-23).
		 * @return int
		 */
		private static function next_utc( $hour ) {
			$now  = time();
			$next = strtotime( gmdate( 'Y-m-d', $now ) . sprintf( ' %02d:00:00 UTC', (int) $hour ) );
			if ( $next <= $now ) {
				$next += DAY_IN_SECONDS;
			}
			return $next;
		}

		/**
		 * Diagnostic log line.
		 *
		 * @param string $message Message.
		 * @return void
		 */
		private static function log( $message ) {
			error_log( '[PK Alerts] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- alerting diagnostics, matches estate convention.
		}
	}
}

// The global contract function lives beside the class it fronts by design:
// pk_signal_alert() IS the public API named in the build brief, and every
// bundling plugin expects it defined once the alerts payload loads.
if ( ! function_exists( 'pk_signal_alert' ) ) {
	/**
	 * Raise a signal alert. The one entry point every detection rule calls.
	 *
	 * Safe to call unguarded from any PublisherKits plugin that bundles the
	 * core; callers that may run without it should wrap in function_exists().
	 *
	 * @since 0.9.0
	 * @param array $args See PKCORE_Alerts::raise().
	 * @return int Row id in the alerts table, or 0.
	 */
	function pk_signal_alert( array $args ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- the contract function must ship beside its class.
		return PKCORE_Alerts::raise( $args );
	}
}
