<?php
/**
 * WooCommerce Log Table List
 *
 * @author   WooThemes
 * @category Admin
 * @package  WooCommerce\Admin
 * @version  1.0.0
 */

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

if ( ! class_exists( 'WP_List_Table' ) ) {
	require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

class WC_Admin_Log_Table_List extends WP_List_Table {
	/**
	 * The key for the user option of how many list table items to display per page.
	 *
	 * @const string
	 */
	public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_status_log_items_per_page';

	/**
	 * The key for the option that stores the list of unique sources that exist in the log table.
	 *
	 * @const string
	 */
	public const SOURCE_CACHE_OPTION_KEY = 'woocommerce_status_log_db_sources';

	/**
	 * If the number of log entries is over this number, cache the query that gets the total count.
	 */
	private const ITEM_COUNT_CACHE_THRESHOLD = 100000;

	/**
	 * Initialize the log table list.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'log',
				'plural'   => 'logs',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Display level dropdown
	 *
	 * @global wpdb $wpdb
	 */
	public function level_dropdown() {
		$labels = WC_Log_Levels::get_all_level_labels();

		$levels = array_reduce(
			array_keys( $labels ),
			function( $carry, $item ) use ( $labels ) {
				$carry[] = array(
					'value' => $item,
					'label' => $labels[ $item ],
				);

				return $carry;
			},
			array()
		);

		$selected_level = isset( $_REQUEST['level'] ) ? $_REQUEST['level'] : '';
		?>
			<label for="filter-by-level" class="screen-reader-text"><?php esc_html_e( 'Filter by level', 'woocommerce' ); ?></label>
			<select name="level" id="filter-by-level">
				<option<?php selected( $selected_level, '' ); ?> value=""><?php esc_html_e( 'All levels', 'woocommerce' ); ?></option>
				<?php
				foreach ( $levels as $l ) {
					printf(
						'<option%1$s value="%2$s">%3$s</option>',
						selected( $selected_level, $l['value'], false ),
						esc_attr( $l['value'] ),
						esc_html( $l['label'] )
					);
				}
				?>
			</select>
		<?php
	}

	/**
	 * Generates the table rows.
	 *
	 * @return void
	 */
	public function display_rows() {
		foreach ( $this->items as $log ) {
			$this->single_row( $log );
			if ( ! empty( $log['context'] ) ) {
				$this->context_row( $log );
			}
		}
	}

	/**
	 * Render the additional table row that contains extra log context data.
	 *
	 * @param array $log Log entry data.
	 *
	 * @return void
	 */
	protected function context_row( $log ) {
		// Maintains alternating row background colors.
		?>
		<tr style="display: none"><td></td></tr>
		<tr id="log-context-<?php echo esc_attr( $log['log_id'] ); ?>" class="log-context">
			<td colspan="<?php echo esc_attr( $this->get_column_count() ); ?>">
				<p><strong><?php esc_html_e( 'Additional context', 'woocommerce' ); ?></strong></p>
				<pre><?php echo esc_html( $log['context'] ); ?></pre>
			</td>
		</tr>
		<?php
	}

	/**
	 * Get list columns.
	 *
	 * @return array
	 */
	public function get_columns() {
		return array(
			'cb'        => '<input type="checkbox" />',
			'timestamp' => __( 'Timestamp', 'woocommerce' ),
			'level'     => __( 'Level', 'woocommerce' ),
			'message'   => __( 'Message', 'woocommerce' ),
			'source'    => __( 'Source', 'woocommerce' ),
			'context'   => __( 'Context', 'woocommerce' ),
		);
	}

	/**
	 * Column cb.
	 *
	 * @param  array $log
	 * @return string
	 */
	public function column_cb( $log ) {
		return sprintf( '<input type="checkbox" name="log[]" value="%1$s" />', esc_attr( $log['log_id'] ) );
	}

	/**
	 * Timestamp column.
	 *
	 * @param  array $log
	 * @return string
	 */
	public function column_timestamp( $log ) {
		return esc_html(
			mysql2date(
				'Y-m-d H:i:s',
				$log['timestamp']
			)
		);
	}

	/**
	 * Level column.
	 *
	 * @param  array $log
	 * @return string
	 */
	public function column_level( $log ) {
		$level_key = WC_Log_Levels::get_severity_level( $log['level'] );
		$levels    = WC_Log_Levels::get_all_level_labels();

		if ( ! isset( $levels[ $level_key ] ) ) {
			return '';
		}

		$level       = $levels[ $level_key ];
		$level_class = sanitize_html_class( 'log-level--' . $level_key );
		return '<span class="log-level ' . $level_class . '">' . esc_html( $level ) . '</span>';
	}

	/**
	 * Message column.
	 *
	 * @param  array $log
	 * @return string
	 */
	public function column_message( $log ) {
		return sprintf(
			'<pre>%s</pre>',
			esc_html( $log['message'] )
		);
	}

	/**
	 * Source column.
	 *
	 * @param  array $log
	 * @return string
	 */
	public function column_source( $log ) {
		return esc_html( $log['source'] );
	}

	/**
	 * Context column.
	 *
	 * @param array $log Log entry data.
	 *
	 * @return string
	 */
	public function column_context( $log ) {
		$content = '';

		if ( ! empty( $log['context'] ) ) {
			ob_start();
			?>
				<button
					class="log-toggle button button-secondary button-small"
					data-log-id="<?php echo esc_attr( $log['log_id'] ); ?>"
					data-toggle-status="off"
					data-label-show="<?php esc_attr_e( 'Show context', 'woocommerce' ); ?>"
					data-label-hide="<?php esc_attr_e( 'Hide context', 'woocommerce' ); ?>"
				>
					<span class="dashicons dashicons-arrow-down-alt2"></span>
					<span class="log-toggle-label screen-reader-text"><?php esc_html_e( 'Show context', 'woocommerce' ); ?></span>
				</button>
			<?php
			$content = ob_get_clean();
		}

		return $content;
	}

	/**
	 * Get bulk actions.
	 *
	 * @return array
	 */
	protected function get_bulk_actions() {
		return array(
			'delete' => __( 'Delete', 'woocommerce' ),
		);
	}

	/**
	 * Extra controls to be displayed between bulk actions and pagination.
	 *
	 * @param string $which
	 */
	protected function extra_tablenav( $which ) {
		if ( 'top' === $which ) {
			echo '<div class="alignleft actions">';
				$this->level_dropdown();
				$this->source_dropdown();
				submit_button( __( 'Filter', 'woocommerce' ), '', 'filter-action', false );
			echo '</div>';
		}
	}

	/**
	 * Get a list of sortable columns.
	 *
	 * @return array
	 */
	protected function get_sortable_columns() {
		return array(
			'timestamp' => array( 'timestamp', true ),
			'level'     => array( 'level', true ),
			'source'    => array( 'source', true ),
		);
	}

	/**
	 * Display source dropdown
	 *
	 * @global wpdb $wpdb
	 */
	protected function source_dropdown() {
		$sources = $this->get_sources();

		if ( ! empty( $sources ) ) {
			$selected_source = isset( $_REQUEST['source'] ) ? $_REQUEST['source'] : '';
			?>
				<label for="filter-by-source" class="screen-reader-text"><?php esc_html_e( 'Filter by source', 'woocommerce' ); ?></label>
				<select name="source" id="filter-by-source">
					<option<?php selected( $selected_source, '' ); ?> value=""><?php esc_html_e( 'All sources', 'woocommerce' ); ?></option>
					<?php
					foreach ( $sources as $s ) {
						printf(
							'<option%1$s value="%2$s">%3$s</option>',
							selected( $selected_source, $s, false ),
							esc_attr( $s ),
							esc_html( $s )
						);
					}
					?>
				</select>
			<?php
		}
	}

	/**
	 * Get the list of unique sources in the log table.
	 *
	 * The query in this method can be slow when there are a high number of log entries. The list of sources also
	 * most likely doesn't change that often. So this indefinitely caches the list into the WP options table. The
	 * cache will get cleared by the log handler if a new source is being added. See WC_Log_Handler_DB::handle().
	 *
	 * @return array
	 */
	protected function get_sources() {
		global $wpdb;

		$sources = get_option( self::SOURCE_CACHE_OPTION_KEY, null );
		if ( is_array( $sources ) ) {
			return $sources;
		}

		$sql = "
			SELECT DISTINCT source
			FROM {$wpdb->prefix}woocommerce_log
			WHERE source != ''
			ORDER BY source ASC
		";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Not necessary.
		$sources = $wpdb->get_col( $sql );

		update_option( self::SOURCE_CACHE_OPTION_KEY, $sources );

		return $sources;
	}

	/**
	 * Prepare table list items.
	 *
	 * @global wpdb $wpdb
	 */
	public function prepare_items() {
		global $wpdb;

		$this->prepare_column_headers();

		$per_page = $this->get_items_per_page(
			self::PER_PAGE_USER_OPTION_KEY,
			$this->get_per_page_default()
		);

		$where  = $this->get_items_query_where();
		$order  = $this->get_items_query_order();
		$limit  = $this->get_items_query_limit();
		$offset = $this->get_items_query_offset();

		$query_items = "
			SELECT log_id, timestamp, level, message, source, context
			FROM {$wpdb->prefix}woocommerce_log
			{$where} {$order} {$limit} {$offset}
		";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The query parts are prepared in their respective methods.
		$this->items = $wpdb->get_results( $query_items, ARRAY_A );
		$total_items = $this->get_total_items_count();

		$this->set_pagination_args(
			array(
				'total_items' => $total_items,
				'per_page'    => $per_page,
				'total_pages' => ceil( $total_items / $per_page ),
			)
		);
	}

	/**
	 * Get the total count of log entries in the database.
	 *
	 * The query in this method can be slow if there are a large (100k+) rows in the database table, so this
	 * uses a transient to cache the count for 10 minutes if the count is over that threshold.
	 *
	 * @return int
	 */
	protected function get_total_items_count() {
		global $wpdb;

		$where         = $this->get_items_query_where();
		$version       = \WC_Cache_Helper::get_transient_version( 'logs-db' );
		$transient_key = 'wc-log-total-items-count-' . md5( $where );
		$transient     = get_transient( $transient_key );
		if (
			false !== $transient
			&& isset( $transient['value'], $transient['version'] )
			&& $transient['version'] === $version
		) {
			return $transient['value'];
		}

		$count_query = "
			SELECT COUNT(*)
			FROM {$wpdb->prefix}woocommerce_log
			{$where}
		";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The where clause is prepared in a separate method.
		$count = intval( $wpdb->get_var( $count_query ) );

		if ( $count > self::ITEM_COUNT_CACHE_THRESHOLD ) {
			$transient = array(
				'value'   => $count,
				'version' => \WC_Cache_Helper::get_transient_version( 'logs-db', true ),
			);

			set_transient( $transient_key, $transient, 10 * MINUTE_IN_SECONDS );
		} else {
			delete_transient( $transient_key );
		}

		return $count;
	}

	/**
	 * Get prepared LIMIT clause for items query
	 *
	 * @global wpdb $wpdb
	 *
	 * @return string Prepared LIMIT clause for items query.
	 */
	protected function get_items_query_limit() {
		global $wpdb;

		$per_page = $this->get_items_per_page(
			self::PER_PAGE_USER_OPTION_KEY,
			$this->get_per_page_default()
		);

		return $wpdb->prepare( 'LIMIT %d', $per_page );
	}

	/**
	 * Get prepared OFFSET clause for items query
	 *
	 * @global wpdb $wpdb
	 *
	 * @return string Prepared OFFSET clause for items query.
	 */
	protected function get_items_query_offset() {
		global $wpdb;

		$per_page     = $this->get_items_per_page(
			self::PER_PAGE_USER_OPTION_KEY,
			$this->get_per_page_default()
		);
		$current_page = $this->get_pagenum();
		if ( 1 < $current_page ) {
			$offset = $per_page * ( $current_page - 1 );
		} else {
			$offset = 0;
		}

		return $wpdb->prepare( 'OFFSET %d', $offset );
	}

	/**
	 * Get prepared ORDER BY clause for items query
	 *
	 * @return string Prepared ORDER BY clause for items query.
	 */
	protected function get_items_query_order() {
		$valid_orders = array( 'level', 'source', 'timestamp' );
		if ( ! empty( $_REQUEST['orderby'] ) && in_array( $_REQUEST['orderby'], $valid_orders ) ) {
			$by = wc_clean( $_REQUEST['orderby'] );
		} else {
			$by = 'log_id';
		}
		$by = esc_sql( $by );

		if ( ! empty( $_REQUEST['order'] ) && 'asc' === strtolower( $_REQUEST['order'] ) ) {
			$order = 'ASC';
		} else {
			$order = 'DESC';
		}

		$orderby = "ORDER BY {$by} {$order}";
		if ( 'log_id' !== $by ) {
			$orderby .= ", log_id {$order}";
		}

		return $orderby;
	}

	/**
	 * Get prepared WHERE clause for items query
	 *
	 * @global wpdb $wpdb
	 *
	 * @return string Prepared WHERE clause for items query.
	 */
	protected function get_items_query_where() {
		global $wpdb;

		$where_conditions = array();
		$where_values     = array();
		if ( ! empty( $_REQUEST['level'] ) && WC_Log_Levels::is_valid_level( $_REQUEST['level'] ) ) {
			$where_conditions[] = 'level >= %d';
			$where_values[]     = WC_Log_Levels::get_level_severity( $_REQUEST['level'] );
		}
		if ( ! empty( $_REQUEST['source'] ) ) {
			$where_conditions[] = 'source = %s';
			$where_values[]     = wc_clean( $_REQUEST['source'] );
		}
		if ( ! empty( $_REQUEST['s'] ) ) {
			$where_conditions[] = 'message like %s';
			$where_values[]     = '%' . $wpdb->esc_like( wc_clean( wp_unslash( $_REQUEST['s'] ) ) ) . '%';
		}

		if ( empty( $where_conditions ) ) {
			return '';
		}

		return $wpdb->prepare( 'WHERE 1 = 1 AND ' . implode( ' AND ', $where_conditions ), $where_values );
	}

	/**
	 * Set _column_headers property for table list
	 */
	protected function prepare_column_headers() {
		$this->_column_headers = array(
			$this->get_columns(),
			array(),
			$this->get_sortable_columns(),
		);
	}

	/**
	 * Helper to get the default value for the per_page arg.
	 *
	 * @return int
	 */
	public function get_per_page_default(): int {
		return 20;
	}
}
