<?php
/**
 * Bounded, coalesced search-protection event log.
 *
 * @package TechDexSearchScrapeGuard
 */

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

final class TDSSG_Logger {
	const SCHEMA_VERSION = '2';
	const SCHEMA_OPTION  = 'tdssg_log_schema_version';
	const MAX_ROWS       = 250;

	/**
	 * Return the event table name.
	 *
	 * @return string
	 */
	public static function table_name() {
		global $wpdb;

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

	/**
	 * Install or update the bounded event table.
	 */
	public static function install() {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table   = self::table_name();
		$charset = $wpdb->get_charset_collate();
		$sql     = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			fingerprint char(64) NOT NULL,
			first_seen datetime NOT NULL,
			last_seen datetime NOT NULL,
			event_count bigint(20) unsigned NOT NULL DEFAULT 1,
			network_masked varchar(64) NOT NULL DEFAULT '',
			reason varchar(32) NOT NULL DEFAULT '',
			status_code smallint(5) unsigned NOT NULL DEFAULT 0,
			search_sample varchar(190) NOT NULL DEFAULT '',
			user_agent varchar(512) NOT NULL DEFAULT '',
			PRIMARY KEY  (id),
			UNIQUE KEY fingerprint (fingerprint),
			KEY last_seen (last_seen)
		) {$charset};";

		dbDelta( $sql );
		delete_option( 'tdssg_summary_counters' );
		update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false );
	}

	/**
	 * Install the table once after an in-place plugin update.
	 */
	public static function maybe_upgrade() {
		if ( self::SCHEMA_VERSION !== (string) get_option( self::SCHEMA_OPTION, '' ) ) {
			self::install();
		}
	}

	/**
	 * Coalesce an event into a bounded signature row.
	 *
	 * One row represents repeat requests sharing the same masked network,
	 * reason, status, and browser string. The latest search is retained as a
	 * sample, and the table is capped at MAX_ROWS rows.
	 *
	 * @param string $reason      Rejection or challenge reason.
	 * @param int    $status_code HTTP status code.
	 * @param string $ip          Connection IP.
	 * @param string $user_agent  Reported user-agent string.
	 * @param string $search      Search term sample.
	 */
	public static function write( $reason, $status_code, $ip, $user_agent, $search ) {
		global $wpdb;

		$table          = self::table_name();
		$network_masked = self::masked_network( $ip );
		$reason         = substr( sanitize_key( $reason ), 0, 32 );
		$status_code    = absint( $status_code );
		$user_agent     = self::truncate( sanitize_text_field( $user_agent ), 512 );
		$search         = self::truncate( sanitize_text_field( $search ), 190 );
		$fingerprint    = hash(
			'sha256',
			implode(
				'|',
				array(
					$network_masked,
					$reason,
					(string) $status_code,
					$user_agent,
				)
			)
		);
		$now            = current_time( 'mysql', true );

		$result = $wpdb->query(
			$wpdb->prepare(
				"INSERT INTO {$table}
					(fingerprint, first_seen, last_seen, event_count, network_masked, reason, status_code, search_sample, user_agent)
				VALUES (%s, %s, %s, 1, %s, %s, %d, %s, %s)
				ON DUPLICATE KEY UPDATE
					last_seen = VALUES(last_seen),
					event_count = event_count + 1,
					search_sample = VALUES(search_sample)",
				$fingerprint,
				$now,
				$now,
				$network_masked,
				$reason,
				$status_code,
				$search,
				$user_agent
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( 1 === $result ) {
			self::enforce_row_cap();
		}
	}

	/**
	 * Return a page of the latest signature rows.
	 *
	 * @param int    $limit  Page size.
	 * @param int    $offset Page offset.
	 * @param string $search Optional text search.
	 * @return array
	 */
	public static function recent( $limit = 50, $offset = 0, $search = '' ) {
		global $wpdb;

		$limit  = max( 1, min( 50, absint( $limit ) ) );
		$offset = max( 0, absint( $offset ) );
		$table  = self::table_name();
		$where  = self::search_where( $search );
		$args   = $where['args'];
		$args[] = $limit;
		$args[] = $offset;
		$sql    = "SELECT * FROM {$table} {$where['sql']} ORDER BY last_seen DESC, id DESC LIMIT %d OFFSET %d";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table and clause are internally generated; all request values remain placeholders.
		return $wpdb->get_results( $wpdb->prepare( $sql, $args ) );
	}

	/**
	 * Count matching signature rows.
	 *
	 * @param string $search Optional text search.
	 * @return int
	 */
	public static function count( $search = '' ) {
		global $wpdb;

		$table = self::table_name();
		$where = self::search_where( $search );
		$sql   = "SELECT COUNT(*) FROM {$table} {$where['sql']}";

		if ( empty( $where['args'] ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The query contains only the plugin-owned table name and a fixed clause.
			return (int) $wpdb->get_var( $sql );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table and clause are internally generated; all request values remain placeholders.
		return (int) $wpdb->get_var( $wpdb->prepare( $sql, $where['args'] ) );
	}

	/**
	 * Clear the bounded event table.
	 *
	 * @return int|false
	 */
	public static function clear() {
		global $wpdb;

		$table = self::table_name();

		return $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Build a safe LIKE clause over the operational fields.
	 *
	 * @param string $search Search text.
	 * @return array
	 */
	private static function search_where( $search ) {
		global $wpdb;

		$search = self::truncate( sanitize_text_field( $search ), 100 );

		if ( '' === $search ) {
			return array(
				'sql'  => '',
				'args' => array(),
			);
		}

		$like = '%' . $wpdb->esc_like( $search ) . '%';

		return array(
			'sql'  => 'WHERE network_masked LIKE %s OR reason LIKE %s OR search_sample LIKE %s OR user_agent LIKE %s',
			'args' => array( $like, $like, $like, $like ),
		);
	}

	/**
	 * Keep only the most recently active signature rows.
	 */
	private static function enforce_row_cap() {
		global $wpdb;

		$table = self::table_name();
		$limit = self::MAX_ROWS;
		$sql   = "DELETE FROM {$table}
			WHERE id NOT IN (
				SELECT id FROM (
					SELECT id FROM {$table}
					ORDER BY last_seen DESC, id DESC
					LIMIT {$limit}
				) AS tdssg_recent
			)";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The statement contains only the plugin-owned table name and the class row-cap constant.
		$wpdb->query( $sql );
	}

	/**
	 * Return a privacy-preserving /24 or /48 network.
	 *
	 * @param string $ip IP address.
	 * @return string
	 */
	private static function masked_network( $ip ) {
		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			$parts    = explode( '.', $ip );
			$parts[3] = '0';

			return implode( '.', $parts ) . '/24';
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			$packed = inet_pton( $ip );
			$masked = substr( $packed, 0, 6 ) . str_repeat( "\0", 10 );

			return inet_ntop( $masked ) . '/48';
		}

		return 'unknown';
	}

	/**
	 * Truncate text without breaking multibyte input.
	 *
	 * @param string $value  Text.
	 * @param int    $length Maximum characters.
	 * @return string
	 */
	private static function truncate( $value, $length ) {
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( (string) $value, 0, $length, 'UTF-8' );
		}

		return substr( (string) $value, 0, $length );
	}
}
