<?php
/**
 * WordPress database inventory.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Detection\Database;

use LocalGuardScanner\Domain\Detection\ScannableObject;
use RuntimeException;

/**
 * Reads only explicitly supported WordPress tables and columns.
 */
final class DatabaseInventory {
	const MAX_VALUE_BYTES = 262144;
	const SAMPLE_BYTES    = 131072;

	/** @var \wpdb */
	private $wpdb;
	/** @var SafeSerializedReader */
	private $serialized_reader;
	/** @var callable */
	private $clock;

	/**
	 * @param \wpdb                $wpdb              Database connection.
	 * @param SafeSerializedReader $serialized_reader Safe reader.
	 * @param callable|null        $clock             Unix time reader.
	 */
	public function __construct( $wpdb, SafeSerializedReader $serialized_reader, $clock = null ) {
		$this->wpdb              = $wpdb;
		$this->serialized_reader = $serialized_reader;
		$this->clock             = null !== $clock ? $clock : 'time';
	}

	/**
	 * @param int $blog_id Site ID.
	 * @param int $after_id Last user ID.
	 * @param int $limit Batch size.
	 * @return DatabaseBatch
	 */
	public function users_batch( $blog_id, $after_id, $limit ) {
		$capability_key = $this->wpdb->get_blog_prefix( $blog_id ) . 'capabilities';
		$query          = $this->wpdb->prepare(
			'SELECT u.ID, u.user_login, u.user_email, u.user_registered, um.meta_value AS capabilities
			FROM %i u LEFT JOIN %i um ON um.user_id = u.ID AND um.meta_key = %s
			WHERE u.ID > %d ORDER BY u.ID ASC LIMIT %d',
			$this->wpdb->users,
			$this->wpdb->usermeta,
			$capability_key,
			(int) $after_id,
			max( 1, (int) $limit )
		);
		$rows           = $this->wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->ensure_query_succeeded();
		$items  = array();
		$cursor = (int) $after_id;
		foreach ( $rows as $row ) {
			$cursor       = (int) $row['ID'];
			$capabilities = array();
			if ( isset( $row['capabilities'] ) && is_string( $row['capabilities'] ) ) {
				try {
					$decoded      = $this->serialized_reader->decode( $row['capabilities'] );
					$capabilities = is_array( $decoded ) ? $decoded : array();
				} catch ( \RuntimeException $error ) {
					$capabilities = array();
				}
			}
			$registered = isset( $row['user_registered'] ) ? strtotime( $row['user_registered'] . ' UTC' ) : false;
			$items[]    = new ScannableObject(
				'db_user',
				'users/' . $cursor . '/site/' . (int) $blog_id,
				array(
					'user_id'          => $cursor,
					'login'            => (string) $row['user_login'],
					'email_hash'       => hash( 'sha256', strtolower( (string) $row['user_email'] ) ),
					'capabilities'     => $capabilities,
					'is_administrator' => ! empty( $capabilities['administrator'] ),
					'is_recent'        => false !== $registered && (int) call_user_func( $this->clock ) - $registered <= 7 * DAY_IN_SECONDS,
				)
			);
		}
		return new DatabaseBatch( $items, $cursor, count( $rows ) < max( 1, (int) $limit ) );
	}

	/**
	 * @param int $blog_id Site ID.
	 * @param int $after_id Last option ID.
	 * @param int $limit Batch size.
	 * @return DatabaseBatch
	 */
	public function options_batch( $blog_id, $after_id, $limit ) {
		$table = $this->wpdb->get_blog_prefix( $blog_id ) . 'options';
		$query = $this->wpdb->prepare(
			'SELECT option_id, option_name, autoload, OCTET_LENGTH(option_value) AS content_size, SHA2(option_value, 256) AS value_hash,
			CASE WHEN OCTET_LENGTH(option_value) <= %d THEN option_value ELSE CONCAT(LEFT(option_value, %d), RIGHT(option_value, %d)) END AS option_value
			FROM %i WHERE option_id > %d ORDER BY option_id ASC LIMIT %d',
			self::MAX_VALUE_BYTES,
			self::SAMPLE_BYTES,
			self::SAMPLE_BYTES,
			$table,
			(int) $after_id,
			max( 1, (int) $limit )
		);
		$rows  = $this->wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->ensure_query_succeeded();
		$items     = array();
		$cursor    = (int) $after_id;
		$omissions = array();
		foreach ( $rows as $row ) {
			$cursor       = (int) $row['option_id'];
			$value        = (string) $row['option_value'];
			$content_size = isset( $row['content_size'] ) ? (int) $row['content_size'] : strlen( $value );
			if ( $content_size > self::MAX_VALUE_BYTES ) {
				$omissions['db_value_size_limit'] = isset( $omissions['db_value_size_limit'] ) ? $omissions['db_value_size_limit'] + 1 : 1;
			}
			$safe_value = null;
			if ( 'cron' === $row['option_name'] ) {
				try {
					$safe_value = $this->serialized_reader->decode( $value );
				} catch ( \RuntimeException $error ) {
					$safe_value = null;
				}
			}
			$items[] = new ScannableObject(
				'db_option',
				'options/' . rawurlencode( (string) $row['option_name'] ) . '/site/' . (int) $blog_id,
				array(
					'option_name' => (string) $row['option_name'],
					'value'       => $value,
					'value_hash'  => isset( $row['value_hash'] ) && is_string( $row['value_hash'] ) && 64 === strlen( $row['value_hash'] ) ? strtolower( $row['value_hash'] ) : hash( 'sha256', $value ),
					'safe_value'  => $safe_value,
					'autoload'    => in_array( (string) $row['autoload'], array( 'yes', 'on', 'auto', 'auto-on' ), true ),
					'size'        => $content_size,
				)
			);
		}
		return new DatabaseBatch( $items, $cursor, count( $rows ) < max( 1, (int) $limit ), $omissions );
	}

	/**
	 * @param int $blog_id Site ID.
	 * @param int $after_id Last post ID.
	 * @param int $limit Batch size.
	 * @return DatabaseBatch
	 */
	public function content_batch( $blog_id, $after_id, $limit ) {
		$table = $this->wpdb->get_blog_prefix( $blog_id ) . 'posts';
		$query = $this->wpdb->prepare(
			'SELECT ID, post_type, post_modified_gmt, OCTET_LENGTH(post_content) AS content_size, SHA2(post_content, 256) AS content_hash,
			CASE WHEN OCTET_LENGTH(post_content) <= %d THEN post_content ELSE CONCAT(LEFT(post_content, %d), RIGHT(post_content, %d)) END AS post_content
			FROM %i WHERE ID > %d ORDER BY ID ASC LIMIT %d',
			self::MAX_VALUE_BYTES,
			self::SAMPLE_BYTES,
			self::SAMPLE_BYTES,
			$table,
			(int) $after_id,
			max( 1, (int) $limit )
		);
		$rows  = $this->wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->ensure_query_succeeded();
		$items     = array();
		$cursor    = (int) $after_id;
		$omissions = array();
		foreach ( $rows as $row ) {
			$cursor       = (int) $row['ID'];
			$content      = (string) $row['post_content'];
			$content_size = isset( $row['content_size'] ) ? (int) $row['content_size'] : strlen( $content );
			if ( $content_size > self::MAX_VALUE_BYTES ) {
				$omissions['db_value_size_limit'] = isset( $omissions['db_value_size_limit'] ) ? $omissions['db_value_size_limit'] + 1 : 1;
			}
			$items[] = new ScannableObject(
				'db_content',
				'content/' . $cursor . '/site/' . (int) $blog_id,
				array(
					'post_id'      => $cursor,
					'post_type'    => (string) $row['post_type'],
					'content'      => $content,
					'content_hash' => isset( $row['content_hash'] ) && is_string( $row['content_hash'] ) && 64 === strlen( $row['content_hash'] ) ? strtolower( $row['content_hash'] ) : hash( 'sha256', $content ),
					'modified_gmt' => (string) $row['post_modified_gmt'],
					'size'         => $content_size,
				)
			);
		}
		return new DatabaseBatch( $items, $cursor, count( $rows ) < max( 1, (int) $limit ), $omissions );
	}

	/**
	 * Count the objects that the supported database batches can inspect.
	 *
	 * @param int[] $blog_ids Included site IDs.
	 * @return int
	 */
	public function count_supported_objects( array $blog_ids ): int {
		$total = 0;
		foreach ( $blog_ids as $blog_id ) {
			$total += $this->count_table( $this->wpdb->users );
			$total += $this->count_table( $this->wpdb->get_blog_prefix( (int) $blog_id ) . 'options' );
			$total += $this->count_table( $this->wpdb->get_blog_prefix( (int) $blog_id ) . 'posts' );
		}
		return max( 0, $total );
	}

	/** @param string $table Supported table. @return int */
	private function count_table( $table ): int {
		$query = $this->wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table );
		$count = $this->wpdb->get_var( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$this->ensure_query_succeeded();
		return max( 0, (int) $count );
	}

	/** @return void */
	private function ensure_query_succeeded() {
		if ( '' !== $this->wpdb->last_error ) {
			throw new RuntimeException( 'A supported WordPress database table could not be read safely.' );
		}
	}
}
