<?php
/**
 * Structured logger backed by the wp_backreload_logs table.
 *
 * Performance: log entries are buffered and flushed in batches. A single backup
 * emits 60–100+ lines; one INSERT per line would commit that many times. We
 * accumulate up to FLUSH_THRESHOLD lines in memory, flush as a multi-row insert,
 * and also flush on shutdown to make sure nothing is lost on a normal request end.
 *
 * Reliability: every flush checks the $wpdb->insert/query return value and falls
 * back to PHP's `error_log()` if the DB rejects the write. Previously schema
 * mismatches would silently swallow every log line for an entire run.
 *
 * @package Backreload\Core
 */

namespace Backreload\Core;

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

class Logger {

	const INFO    = 'info';
	const SUCCESS = 'success';
	const WARN    = 'warn';
	const ERROR   = 'error';

	/** Buffer cap before an auto-flush. Small enough that a polling progress
	 *  panel sees recent log lines (it would otherwise sit at "Waiting for
	 *  first log line…" while the cron worker fills its buffer in another
	 *  process), large enough that we still batch ~5x vs per-line inserts. */
	private const FLUSH_THRESHOLD = 5;

	/** @var array<int,array<string,mixed>> Pending rows waiting for a flush. */
	private array $buffer = [];

	private bool $shutdown_registered = false;

	public function __construct() {
		// Table name resolved lazily in flush() so switch_to_blog() works.
	}

	public function info( string $message, array $context = [], ?string $backup_id = null ): void {
		$this->log( self::INFO, $message, $context, $backup_id );
	}

	public function success( string $message, array $context = [], ?string $backup_id = null ): void {
		$this->log( self::SUCCESS, $message, $context, $backup_id );
	}

	public function warn( string $message, array $context = [], ?string $backup_id = null ): void {
		$this->log( self::WARN, $message, $context, $backup_id );
	}

	public function error( string $message, array $context = [], ?string $backup_id = null ): void {
		$this->log( self::ERROR, $message, $context, $backup_id );
	}

	public function log( string $level, string $message, array $context = [], ?string $backup_id = null ): void {
		$this->buffer[] = [
			'backup_ext_id' => $backup_id,
			'level'         => $level,
			'message'       => $message,
			'context'       => $context ? wp_json_encode( $context ) : null,
			'created_at'    => current_time( 'mysql', true ),
		];

		// Register the shutdown flush exactly once per request — guarantees
		// nothing is lost when the request ends cleanly.
		if ( ! $this->shutdown_registered ) {
			$this->shutdown_registered = true;
			register_shutdown_function( [ $this, 'flush' ] );
		}

		// Errors flush immediately — we want them durable even if the engine
		// dies in the next millisecond. Otherwise batch.
		if ( self::ERROR === $level || count( $this->buffer ) >= self::FLUSH_THRESHOLD ) {
			$this->flush();
		}
	}

	/**
	 * Write all pending rows to the DB as a single multi-row insert. Called
	 * automatically at FLUSH_THRESHOLD, after every error(), and at shutdown.
	 * Public so engine checkpoints and WP-CLI can force a flush.
	 */
	public function flush(): void {
		if ( empty( $this->buffer ) ) return;
		global $wpdb;
		$table = $wpdb->prefix . 'backreload_logs';

		// Build a parameterized multi-row INSERT. wpdb has no native multi-row
		// helper so we craft the SQL ourselves with prepare() per placeholder.
		$rows         = $this->buffer;
		$this->buffer = []; // empty up-front so a failure doesn't retry forever

		$placeholders = [];
		$params       = [];
		foreach ( $rows as $r ) {
			$placeholders[] = '(%s, %s, %s, %s, %s)';
			$params[]       = $r['backup_ext_id']; // nullable string
			$params[]       = $r['level'];
			$params[]       = $r['message'];
			$params[]       = $r['context'];
			$params[]       = $r['created_at'];
		}

		$sql = "INSERT INTO `{$table}` (backup_ext_id, level, message, context, created_at) "
		     . 'VALUES ' . implode( ', ', $placeholders );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); plugin writes its own table, result is transient and uncacheable.
		$ok = $wpdb->query( $wpdb->prepare( $sql, $params ) );
		if ( false === $ok ) {
			// DB insert failed — fall back to PHP error_log so we don't lose
			// the entries entirely. Most likely cause is a schema mismatch
			// (column missing) which deactivate+reactivate fixes. This is the
			// ONE error_log path we keep unconditional: if the plugin's own
			// log table is broken, gating on WP_DEBUG would hide the failure
			// from the admins who most need to see it.
			$err = (string) ( $wpdb->last_error ?: 'unknown error' );
			foreach ( $rows as $r ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional fallback path when the plugin's log table is unwritable; lets admins debug a broken Logs page.
				error_log( sprintf(
					'[backreload logger fallback] %s %s%s: %s (DB write failed: %s)',
					$r['level'],
					$r['created_at'],
					$r['backup_ext_id'] ? ' ' . $r['backup_ext_id'] : '',
					$r['message'],
					$err
				) );
			}
		}
	}

	/**
	 * Fetch log lines with filters.
	 *
	 * @param array{level?:string,backup_id?:string,search?:string,limit?:int,offset?:int} $args
	 * @return array<int,array<string,mixed>>
	 */
	public function fetch( array $args = [] ): array {
		// Flush pending entries first so the caller sees their own writes.
		$this->flush();

		global $wpdb;
		$table = $wpdb->prefix . 'backreload_logs';

		$where  = [ '1=1' ];
		$params = [];

		if ( ! empty( $args['level'] ) && 'all' !== $args['level'] ) {
			$where[]  = 'level = %s';
			$params[] = $args['level'];
		}
		if ( ! empty( $args['backup_id'] ) ) {
			$where[]  = 'backup_ext_id = %s';
			$params[] = $args['backup_id'];
		}
		if ( ! empty( $args['search'] ) ) {
			$search = (string) $args['search'];
			$esc    = $wpdb->esc_like( $search );
			// Two-path search strategy designed around the table's existing
			// indices (KEY backup_ext_id, KEY created_at) — the old query
			// did `LIKE '%x%' OR backup_ext_id LIKE 'x%'` which forced a
			// full-table scan even when the user pasted a full backup id,
			// because OR forces evaluation of the unindexed left side
			// across every row.
			if ( 0 === strpos( $search, 'bk_' ) ) {
				// Looks like a backup id (or prefix thereof). Skip the
				// message LIKE entirely; the backup_ext_id index turns this
				// into an O(log n) lookup. Common case — users paste IDs
				// from chips on the Backups page, the BackupProgress
				// detail popover, and the LOGS_TARGET deep-link.
				$where[]  = 'backup_ext_id LIKE %s';
				$params[] = $esc . '%';
			} else {
				// Free-text substring search on the message column. Leading
				// `%` defeats the indices, so this is intrinsically a scan
				// — we bound the scan by recency so the worst case stays
				// linear in the size of recent activity rather than the
				// table's full lifetime. 30 days matches the auto-prune
				// default in Settings → Retention (logs auto-prune at the
				// same horizon, so this clause excludes nothing the user
				// could otherwise reach via search anyway). A user looking
				// for something OLDER can scope the search to a backup_id
				// (the indexed path above).
				if ( empty( $args['backup_id'] ) ) {
					$where[]  = 'created_at >= %s';
					$params[] = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS );
				}
				$where[]  = '( message LIKE %s OR backup_ext_id LIKE %s )';
				$params[] = '%' . $esc . '%';
				$params[] = $esc . '%';
			}
		}

		$limit  = (int) ( $args['limit']  ?? 200 );
		$offset = (int) ( $args['offset'] ?? 0 );

		$sql = "SELECT * FROM `{$table}` WHERE " . implode( ' AND ', $where )
			. ' ORDER BY id DESC LIMIT %d OFFSET %d';
		$params[] = $limit;
		$params[] = $offset;

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); plugin reads its own table, result is transient and uncacheable.
		return $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A ) ?: [];
	}

	public function truncate(): void {
		$this->buffer = []; // drop pending
		global $wpdb;
		$table = $wpdb->prefix . 'backreload_logs';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); plugin manages its own table, result is transient and uncacheable.
		$wpdb->query( "TRUNCATE TABLE `{$table}`" );
	}

	/**
	 * Drop rows older than $days. Called from the scheduled auto-prune to keep
	 * the table from growing forever on busy sites.
	 *
	 * @return int Number of rows deleted (0 if the query failed or had no effect).
	 */
	public function prune( int $days ): int {
		if ( $days <= 0 ) return 0;
		global $wpdb;
		$table  = $wpdb->prefix . 'backreload_logs';
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix (never user input), cannot be bound via prepare(); plugin manages its own table, result is transient and uncacheable.
		$deleted = $wpdb->query( $wpdb->prepare(
			"DELETE FROM `{$table}` WHERE created_at < %s",
			$cutoff
		) );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) ( false === $deleted ? 0 : $deleted );
	}
}
