<?php
/**
 * 404 logging with hit counts — free (the category leader paywalls this).
 *
 * One row per unique path, upserted with an atomic hit counter, capped and
 * auto-pruned so the table can never bloat (a 125k-row log is a real
 * complaint against the incumbents).
 *
 * Row status: 0 = open, 1 = ignored, 2 = resolved (a redirect now covers it).
 *
 * @package RedirectZen
 */

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

class RDZ_404_Log {

	/**
	 * Hooks. Runs late on template_redirect: if the request were covered by
	 * a redirect rule, RDZ_Redirects (priority 0) would already have exited.
	 */
	public static function init() {
		add_action( 'template_redirect', array( __CLASS__, 'maybe_log' ), 20 );
	}

	/**
	 * @return string Fully-prefixed log table name.
	 */
	public static function table() {
		global $wpdb;

		return $wpdb->prefix . 'rdz_404_log';
	}

	/**
	 * Logs the current request when it is a 404.
	 */
	public static function maybe_log() {
		if ( ! is_404() || is_admin() ) {
			return;
		}
		if ( ! RDZ_Settings::get( 'log_404' ) ) {
			return;
		}
		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
			return;
		}

		$path = RDZ_Redirects::normalize( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) );
		if ( '' === $path || '/' === $path ) {
			return;
		}
		if ( strlen( $path ) > 2000 ) {
			return; // Garbage/attack URL, not worth a row.
		}
		if ( self::is_asset_path( $path ) ) {
			return;
		}
		if ( RDZ_Settings::is_ignored_404( $path ) ) {
			return;
		}

		$referrer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';

		self::record( $path, $referrer );
	}

	/**
	 * Whether a path points into plugin/theme/core asset directories.
	 * Missing static assets are a developer concern, not redirect
	 * material — logging them (including our own cross-promo icons
	 * before this plugin update) just pollutes the 404 log.
	 *
	 * @param string $path Normalized request path.
	 * @return bool
	 */
	public static function is_asset_path( $path ) {
		foreach ( array( '/wp-content/plugins/', '/wp-content/themes/', '/wp-includes/' ) as $prefix ) {
			if ( 0 === strpos( $path, $prefix ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Upserts a 404 row (atomic hit increment).
	 *
	 * @param string $path     Normalized path.
	 * @param string $referrer Referrer URL.
	 */
	public static function record( $path, $referrer = '' ) {
		global $wpdb;

		$table = self::table();
		$now   = current_time( 'mysql', true );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"INSERT INTO {$table} (path, path_hash, hits, referrer, first_seen, last_seen, status)
				VALUES (%s, %s, 1, %s, %s, %s, 0)
				ON DUPLICATE KEY UPDATE hits = hits + 1, last_seen = VALUES(last_seen),
					referrer = IF(VALUES(referrer) = '', referrer, VALUES(referrer))",
				$path,
				md5( $path ),
				substr( $referrer, 0, 500 ),
				$now,
				$now
			)
		);
	}

	/**
	 * Fetches one log row.
	 *
	 * @param int $id Row ID.
	 * @return array|null
	 */
	public static function get( $id ) {
		global $wpdb;

		$table = self::table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $id ), ARRAY_A );

		return $row ? $row : null;
	}

	/**
	 * Sets a row's status.
	 *
	 * @param int $id     Row ID.
	 * @param int $status 0 open, 1 ignored, 2 resolved.
	 * @return bool
	 */
	public static function set_status( $id, $status ) {
		global $wpdb;

		return false !== $wpdb->update(
			self::table(),
			array( 'status' => (int) $status ),
			array( 'id' => (int) $id ),
			array( '%d' ),
			array( '%d' )
		);
	}

	/**
	 * Marks any open log rows covered by a source as resolved (called when
	 * a redirect is created).
	 *
	 * @param string $normalized_source Normalized source path.
	 */
	public static function resolve_matching( $normalized_source ) {
		global $wpdb;

		$table = self::table();
		// Exact match plus path-only match for rows logged with query strings.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET status = 2 WHERE status = 0 AND (path_hash = %s OR path LIKE %s)",
				md5( $normalized_source ),
				$wpdb->esc_like( $normalized_source ) . '?%'
			)
		);
	}

	/**
	 * Deletes rows by ID.
	 *
	 * @param int[] $ids Row IDs.
	 * @return int Rows deleted.
	 */
	public static function delete_rows( $ids ) {
		global $wpdb;

		$ids = array_filter( array_map( 'absint', (array) $ids ) );
		if ( empty( $ids ) ) {
			return 0;
		}

		$table        = self::table();
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) );
	}

	/**
	 * Prunes the log: retention window plus a hard row cap. Called daily.
	 */
	public static function prune() {
		global $wpdb;

		$table     = self::table();
		$retention = (int) RDZ_Settings::get( 'retention_days' );
		$cap       = (int) RDZ_Settings::get( 'max_404_rows' );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE last_seen < %s", gmdate( 'Y-m-d H:i:s', time() - $retention * DAY_IN_SECONDS ) ) );

		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
		if ( $total > $cap ) {
			// Drop the least-recently-seen overflow.
			$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} ORDER BY last_seen ASC LIMIT %d", $total - $cap ) );
		}
		// phpcs:enable
	}

	/**
	 * Dashboard counts.
	 *
	 * @return array { open, resolved, ignored, hits_open }
	 */
	public static function get_counts() {
		global $wpdb;

		$table = self::table();
		$rows  = $wpdb->get_results( "SELECT status, COUNT(*) AS n, SUM(hits) AS h FROM {$table} GROUP BY status", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$counts = array(
			'open'      => 0,
			'ignored'   => 0,
			'resolved'  => 0,
			'hits_open' => 0,
		);

		foreach ( (array) $rows as $row ) {
			$n = (int) $row['n'];
			switch ( (int) $row['status'] ) {
				case 0:
					$counts['open']      = $n;
					$counts['hits_open'] = (int) $row['h'];
					break;
				case 1:
					$counts['ignored'] = $n;
					break;
				case 2:
					$counts['resolved'] = $n;
					break;
			}
		}

		return $counts;
	}
}
