<?php

/**
 * PeepSoActivityRanking
 *
 * Thin data-access + event layer over the `peepso_activity_ranking` table. The
 * per-event feeders no longer do any scoring arithmetic themselves: they simply
 * flag the affected thread root dirty via mark_dirty(), and the cron-driven
 * PeepSoActivityRankingEngine owns every recompute (see that class + the
 * PeepSo3_Maintenance_Activity_Ranking maintenance task).
 *
 * Legacy columns (rank_act_comments/likes/shares/score, rank_act_views) are kept
 * and cron-maintained so existing consumers keep working: the groups / pages
 * "Popular Posts" widgets, the admin "Community Pulse" dashboard, and the
 * per-post view-count display.
 */
class PeepSoActivityRanking
{
	const TABLE       = 'peepso_activity_ranking';
	const TABLE_VIEWS = 'peepso_activity_views';	// unique views
	const TABLE_READ  = 'peepso_activity_read';		// unique reads

	public function __construct()
	{
		// The ranking / views / read tables are created and migrated through the
		// standard activation path (PeepSoActivate::get_table_data() + dbDelta and
		// migrate_database_tables()). Nothing to do per-instance.
	}

	/**
	 * Register the ranking event hooks. Called once at plugin init.
	 */
	public static function register_hooks()
	{
		// `peepso_delete_content` fires for every post/comment removed in
		// delete_post() — the common sink for BOTH the ajax delete path and the
		// delete_activity() path — so it reliably catches deletions no matter how
		// they were triggered.
		add_action('peepso_delete_content', array('PeepSoActivityRanking', 'on_delete_content'));

		// #8037 bump the ranking weights "generation" pointer when an admin changes any
		// ranking weight, so every already-scored row (rank_rev < current) is lazily
		// re-scored under the new weights by the ordinary cron sweeps.
		add_action('peepso_action_config_option_change', array('PeepSoActivityRanking', 'on_config_option_change'), 10, 3);

		// #8037 admin gauge: how many ranking rows are waiting to be (re)counted by the cron.
		// Polled from Config › Navigation & Filters › Content ranking.
		add_action('wp_ajax_peepso_ranking_pending', array('PeepSoActivityRanking', 'ajax_pending_count'));

		// #8037 dev aid: with the `debug_ranking` dev mode on, show a small square badge to the LEFT
		// of each post's author avatar with the score for the CURRENT ranking sort (Top/Hot × period).
		// Enable via a `dev_mode_debug_ranking` option or define('PEEPSO_DEV_MODE_DEBUG_RANKING', 1)
		// (or the global PEEPSO_DEV_MODE) in wp-config. Gated here so the filter is NOT registered at
		// all in production — zero overhead when off. Post avatars only, so comments are skipped.
		if (PeepSo::is_dev_mode('debug_ranking')) {
			add_filter('peepso_activity_stream_author_avatar', array('PeepSoActivityRanking', 'debug_avatar_badge'), 999, 5);
		}
	}

	/**
	 * #8037 ajax: how many posts are waiting to be (re)counted by the maintenance cron. Measured
	 * from the POSTS, not the ranking rows, so freshly created / bulk-seeded content shows up
	 * immediately — before the event-tail pass has even created its ranking rows. A published
	 * top-level post is "waiting" when it has NO ranking row yet, OR its row is dirty (new
	 * engagement), OR it was scored under an older weights generation (rank_rev < current).
	 * Feeds the admin "posts waiting re-count" gauge. Admin-only + nonced.
	 *
	 * @return void (JSON: {pending, total, rev})
	 */
	public static function ajax_pending_count()
	{
		if (!current_user_can('manage_options')) {
			wp_send_json_error(array('message' => 'forbidden'), 403);
		}
		check_ajax_referer('peepso_ranking_pending', 'nonce');

		// Drive a ranking tick from the gauge poll so it drains while an admin is watching it — even
		// on a low-traffic site whose WP-Cron rarely fires (or one on external cron). The gauge polls
		// every 15s but we fire the tick at most ONCE A MINUTE (matching the cron cadence), so simply
		// watching the panel can't multiply the write cadence. ranking() itself holds an atomic run
		// lock, so this can never overlap the real cron tick either.
		if (has_action(PeepSo::CRON_RANKING_EVENT) && !get_transient('peepso_ranking_poll_tick')) {
			set_transient('peepso_ranking_poll_tick', 1, MINUTE_IN_SECONDS);
			do_action(PeepSo::CRON_RANKING_EVENT);
		}

		if (!class_exists('PeepSoActivityRankingEngine')) {
			wp_send_json_success(array('pending' => 0, 'total' => 0, 'rev' => 1));
		}

		$s = PeepSoActivityRankingEngine::post_processing_stats();
		// #8037 total (published posts) and processed (ranking rows) are counted from two different
		// tables for scale, so a tiny cross-table residual can persist after everything is effectively
		// ranked. Treat a sub-threshold tail as done so the gauge doesn't sit on "N pending" forever.
		// Filterable; post_processing_stats() and the rollout reveal keep the true numbers, and the
		// dev overlay still reads true per-post state.
		$floor   = max(0, intval(apply_filters('peepso_filter_ranking_pending_floor', 10)));
		$pending = ($s['pending'] < $floor) ? 0 : $s['pending'];
		wp_send_json_success(array(
			'pending' => $pending,
			'total'   => $s['total'],
			'rev'     => PeepSoActivityRankingEngine::weights_rev(),
		));
	}

	/**
	 * #8037 dev overlay: a small SQUARE badge rendered to the LEFT of a post's author avatar,
	 * showing the score for the sort the stream is CURRENTLY ordered by (Top or Hot × period),
	 * read from the same $_REQUEST['stream_filter_sort_by'] the query uses (so it always matches
	 * the real ORDER BY). Hidden on a non-ranking sort (Recent / new_and_commented / pinned) and
	 * on comments (this filter fires for POST avatars only). Hot shows the EFFECTIVE (read-time
	 * decayed) value that actually orders the stream. Stacked lines: "Fam • Period", the rank
	 * number, decayed (Hot only), up-to-date / RECOUNT flag, and rev x/y — so you can still watch a
	 * re-score drain. Registered by register_hooks() only when is_dev_mode('debug_ranking').
	 *
	 * @param  string    $output           the avatar <a> markup
	 * @param  int       $post_id          the post ID whose avatar is rendering
	 * @param  string    $hide_post_header hide-header attribute passthrough
	 * @param  int       $post_author      author user id
	 * @param  PeepSoUser $PeepSoUser       author
	 * @return string    badge + avatar, or the avatar unchanged
	 */
	public static function debug_avatar_badge($output, $post_id, $hide_post_header, $post_author, $PeepSoUser)
	{
		if (!class_exists('PeepSoActivityRankingEngine')) {
			return $output;
		}

		// Skip comments — only rank top-level posts.
		if (PeepSoActivityStream::CPT_POST !== get_post_type($post_id)) {
			return $output;
		}

		// Only when the stream is ordered by a ranking sort — and only that sort's score. Mirrors
		// PeepSoActivity::filter_post_clauses so the badge reflects the ORDER BY actually applied.
		$sort_key = (isset($_REQUEST['stream_filter_sort_by']) && is_string($_REQUEST['stream_filter_sort_by']))
			? $_REQUEST['stream_filter_sort_by'] : '';
		$sorts = PeepSoActivityRankingEngine::stream_sorts();
		if (!isset($sorts[$sort_key])) {
			return $output;	// not a Top/Hot sort — no badge
		}
		$spec = $sorts[$sort_key];

		// The stream decorates the current post with its activity id; that keys the ranking row.
		global $post, $wpdb;
		$act_id = (isset($post->act_id) && intval($post->ID) === intval($post_id)) ? intval($post->act_id) : 0;
		if ($act_id <= 0) {
			return $output;
		}
		$table = $wpdb->prefix . self::TABLE;
		$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM `$table` WHERE rank_act_id = %d", $act_id));

		$is_hot  = ('hot' === $spec['type']);
		$famcol  = $is_hot ? '#f88' : '#fc6';
		// Kept in step with the user-facing $periods map in peepso.php (untranslated: dev overlay).
		$periods = array('1d' => 'Today', '7d' => 'One week', '30d' => 'One month', '365d' => 'One year', 'all' => 'All time');
		$period  = substr($sort_key, strrpos($sort_key, '_') + 1);

		$box = 'display:inline-flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;'
			 . 'box-sizing:border-box;width:90px;height:90px;margin-right:8px;padding:5px;vertical-align:top;'
			 . 'background:#14161a;border-radius:6px;color:#7CFC00;line-height:1.15;'
			 . 'font-family:ui-monospace,SFMono-Regular,Menlo,monospace';

		$L1 = '<div style="font-size:12px"><b style="color:' . $famcol . '">' . ($is_hot ? 'Hot' : 'Top') . '</b> • '
			. (isset($periods[$period]) ? $periods[$period] : $period) . '</div>';

		if (NULL === $row) {
			// No ranking row yet (e.g. a brand-new post before the tail pass runs).
			return '<span class="ps-js-rankdebug" style="' . $box . '">' . $L1
				. '<div style="font-size:24px;font-weight:bold;color:#f96">?</div></span>' . $output;
		}

		$col = preg_replace('/[^a-z0-9_]/', '', $spec['col']);
		$age = 0;
		if ($is_hot) {
			// Effective (read-time decayed) Hot: stored * 0.5^(age / half_life) — what orders the stream.
			$anchor = empty($row->rank_hot_ts) ? 0 : strtotime($row->rank_hot_ts . ' UTC');
			$age    = $anchor ? max(0, time() - $anchor) : 0;
			$score  = round(floatval($row->$col) * pow(0.5, $age / max(1, intval($spec['hl']))), 1);
		} else {
			$score = round(floatval($row->$col));
		}

		$cur_rev = PeepSoActivityRankingEngine::weights_rev();
		$stale   = (1 === intval($row->rank_dirty) || intval($row->rank_rev) < $cur_rev);

		$lines  = $L1;
		$lines .= '<div style="font-size:24px;font-weight:bold">' . $score . '</div>';
		if ($is_hot) {
			// What actually drives a Hot score: recency of the newest engagement (human-readable),
			// not the read-time decay anchor. NULL = the post has never had engagement.
			$eng = empty($row->rank_last_engagement)
				? 'no eng'
				: 'eng ' . self::humanize_secs(time() - strtotime($row->rank_last_engagement . ' UTC')) . ' ago';
			$lines .= '<div style="font-size:11px;color:#678">' . $eng . '</div>';
		}
		$lines .= '<div style="font-size:11px;color:' . ($stale ? '#f96">recount' : '#8c8">up to date') . '</div>';
		$lines .= '<div style="font-size:11px;color:#678">rev ' . intval($row->rank_rev) . '/' . intval($cur_rev) . '</div>';

		return '<span class="ps-js-rankdebug" style="' . $box . '">' . $lines . '</span>' . $output;
	}

	/**
	 * Compact human-readable duration for the debug badge: 45s / 32m / 1h4m / 3d5h.
	 * @param  int $s seconds
	 * @return string
	 */
	private static function humanize_secs($s)
	{
		$s = max(0, intval($s));
		if ($s < 60)    { return $s . 's'; }
		if ($s < 3600)  { return intval($s / 60) . 'm'; }
		if ($s < 86400) {
			$h = intval($s / 3600); $m = intval(($s % 3600) / 60);
			return $m ? "{$h}h{$m}m" : "{$h}h";
		}
		$d = intval($s / 86400); $h = intval(($s % 86400) / 3600);
		return $h ? "{$d}d{$h}h" : "{$d}d";
	}

	/**
	 * '#4319' per-option config-change hook. When any of the six per-family ranking weights
	 * actually changes value, increment the weights-generation pointer
	 * (PeepSoActivityRankingEngine::WEIGHTS_REV_OPTION). This is the whole invalidation
	 * mechanism: it is O(1) (one option write, no matter the table size) and the mismatch
	 * `rank_rev < pointer` marks every existing row dirty for the cron to re-score. The hook
	 * only fires when old != new, so a no-op save never moves the pointer; several weights
	 * changing in one save bump it several times, which is harmless (it is just a monotonic
	 * generation counter).
	 *
	 * @param string $option Option name that changed
	 * @param mixed  $new    New value (unused — presence of a change is enough)
	 * @param mixed  $old    Old value (unused)
	 */
	public static function on_config_option_change($option, $new, $old)
	{
		static $weight_opts = array(
			'ranking_weight_top_comment', 'ranking_weight_top_react', 'ranking_weight_top_repost',
			'ranking_weight_hot_comment', 'ranking_weight_hot_react', 'ranking_weight_hot_repost',
		);

		if (!in_array($option, $weight_opts, TRUE)) {
			return;
		}

		$key = class_exists('PeepSoActivityRankingEngine')
			? PeepSoActivityRankingEngine::WEIGHTS_REV_OPTION
			: 'peepso_ranking_weights_rev';

		$rev = max(1, intval(get_option($key, 1)));
		update_option($key, $rev + 1);
	}

	/**
	 * Insert the ranking row for a freshly created activity.
	 * @param  int|array $rank act_id (or a legacy array with pre-computed counters)
	 * @return mixed
	 */
	public function add_new($rank)
	{
		if (is_array($rank)) {
			$aRankData = array(
				'rank_act_id'       => $rank['id'],
				'rank_act_comments' => $rank['comments'],
				'rank_act_likes'    => $rank['likes'],
				'rank_act_shares'   => $rank['shares'],
				'rank_act_date'     => current_time('Y-m-d H:i:s'),
			);
		} else {
			$aRankData = array(
				'rank_act_id'   => $rank,
				'rank_act_date' => current_time('Y-m-d H:i:s'),
			);
		}

		global $wpdb;

		return $wpdb->insert($wpdb->prefix . self::TABLE, $aRankData);
	}

	/**
	 * Remove the ranking row for an activity.
	 * @param int $rank_act_id
	 */
	public function delete($rank_act_id)
	{
		global $wpdb;

		$wpdb->delete($wpdb->prefix . self::TABLE, array('rank_act_id' => intval($rank_act_id)));
	}

	/**
	 * Flag the thread root of an activity as dirty so the next cron tick
	 * recomputes its scores. This is the single choke point for every engagement
	 * event (comment, reply, reaction, like, repost, and their removals). The
	 * cron owns all arithmetic — this is one tiny UPDATE/INSERT per event.
	 *
	 * @param int $act_id Any activity id (post, comment or reply); resolved to its root.
	 */
	public function mark_dirty($act_id)
	{
		$root_id = self::resolve_root_act_id($act_id);
		if ($root_id <= 0) {
			return;
		}

		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;

		// Safe under the UNIQUE key on rank_act_id (added in DB migration 314).
		$wpdb->query($wpdb->prepare(
			"INSERT INTO `$table` (rank_act_id, rank_act_date, rank_dirty)
				VALUES (%d, %s, 1)
				ON DUPLICATE KEY UPDATE rank_dirty = 1",
			$root_id,
			current_time('Y-m-d H:i:s')
		));
	}

	/**
	 * Resolve any activity id to the id of its thread root. PeepSo threads are at
	 * most two levels deep (comment, reply); the loop is bounded defensively.
	 *
	 * @param  int $act_id
	 * @return int Root activity id, or 0 if it cannot be resolved.
	 */
	public static function resolve_root_act_id($act_id)
	{
		$act_id = intval($act_id);
		if ($act_id <= 0) {
			return 0;
		}

		global $wpdb;
		$table = $wpdb->prefix . PeepSoActivity::TABLE_NAME;

		$guard = 0;
		while ($guard++ < 3) {
			$row = $wpdb->get_row($wpdb->prepare(
				"SELECT act_id, act_comment_object_id, act_comment_module_id FROM `$table` WHERE act_id = %d LIMIT 1",
				$act_id
			));

			if (!is_object($row)) {
				return 0;
			}

			// A root post has no comment parent.
			if (0 === intval($row->act_comment_object_id)) {
				return intval($row->act_id);
			}

			// Walk up to the parent (identified by external id + module id).
			$parent = $wpdb->get_row($wpdb->prepare(
				"SELECT act_id FROM `$table`
					WHERE act_external_id = %d AND act_module_id = %d
					ORDER BY act_id DESC LIMIT 1",
				$row->act_comment_object_id,
				$row->act_comment_module_id
			));

			if (!is_object($parent)) {
				return 0;
			}

			$act_id = intval($parent->act_id);
		}

		return intval($act_id);
	}

	/**
	 * `peepso_delete_content` callback. Keeps the ranking table clean when content
	 * is removed: a deleted root post drops its ranking row; a deleted comment /
	 * reply flags its root for recompute so the lost engagement is reflected.
	 *
	 * @param int $post_id The WP post id (activity external id) being deleted.
	 */
	public static function on_delete_content($post_id)
	{
		global $wpdb;
		$table = $wpdb->prefix . PeepSoActivity::TABLE_NAME;

		// The activity row still exists at this point (deleted later in delete_post()).
		$act = $wpdb->get_row($wpdb->prepare(
			"SELECT act_id, act_comment_object_id FROM `$table`
				WHERE act_external_id = %d AND act_module_id = %d
				ORDER BY act_id DESC LIMIT 1",
			intval($post_id),
			PeepSoActivity::MODULE_ID
		));

		if (!is_object($act)) {
			return;
		}

		$rank = new self();

		if (0 === intval($act->act_comment_object_id)) {
			// Root post — remove its ranking row entirely.
			$rank->delete($act->act_id);
		} else {
			// Comment / reply — the root loses engagement, recompute it next tick.
			$rank->mark_dirty($act->act_id);
		}
	}

	/* ---------------------------------------------------------------------- *
	 * View counts (display only — deliberately excluded from the score)
	 * ---------------------------------------------------------------------- */

	/**
	 * Increment the raw (non-unique) view counter. Display only; this value is not
	 * part of any engagement score. Gated by the `post_view_count_show` option at
	 * the call site so it is not written on every render when views are hidden.
	 * @param int $rank_act_id
	 * @return mixed
	 */
	public function add_view_count($rank_act_id)
	{
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;

		$sql = "UPDATE $table SET rank_act_views = rank_act_views + 1 WHERE rank_act_id = %d";

		return $wpdb->query($wpdb->prepare($sql, $rank_act_id));
	}

	public static function get_view_count($rank_act_id)
	{
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;

		$sql = "SELECT rank_act_views FROM $table WHERE rank_act_id = %d";
		$res = $wpdb->get_row($wpdb->prepare($sql, $rank_act_id), ARRAY_A);

		return (int) max(1, $res['rank_act_views']);
	}

	// Unique views

	public static function add_unique_view_count($act_id)
	{
		global $wpdb;

		if ($user_id = get_current_user_id()) {
			$table = $wpdb->prefix . self::TABLE_VIEWS;

			$sql = "INSERT IGNORE INTO  $table (`user_id`, `act_id`, `date`) VALUES ($user_id, $act_id, NOW())";
			$wpdb->query($sql);

			if (PeepSo::is_dev_mode('show_unread_posts_and_comments')) {
				$table_read = $wpdb->prefix . self::TABLE_READ;
				$sql = "INSERT IGNORE INTO  $table_read (`user_id`, `act_id`, `date`) VALUES ($user_id, $act_id, NOW())";
				$wpdb->query($sql);
			}

			return TRUE;
		}

		return FALSE;
	}

	public static function get_unique_view_count($rank_act_id)
	{
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_VIEWS;

		$sql = "SELECT COUNT(user_id) as count FROM $table WHERE `act_id`='$rank_act_id'";
		$res = $wpdb->get_results($sql, ARRAY_A);

		if (is_array($res) && count($res)) {
			return max(1, intval($res[0]['count']));
		}

		return 1;
	}

	public static function remove_unique_read($rank_act_id)
	{
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_READ;

		$sql = "DELETE FROM $table WHERE `act_id`='$rank_act_id' AND `user_id` <> " . get_current_user_id();
		$wpdb->query($sql);

		return TRUE;
	}
}

// EOF
