<?php
/**
 * Cross-module debug logger.
 *
 * OFF by default. When the admin enables it (Blaminhor → Logs), every module
 * that calls blaminhor_essentials_log() with a level >= the configured
 * threshold gets its event appended to a JSON Lines file under
 * wp-content/uploads/blaminhor-essentials/logs/events.jsonl.
 *
 * The point: let the admin reproduce a bug with logging on, copy or download
 * the resulting JSON, and send it for diagnosis without having to dig through
 * the browser DevTools or wp-content/debug.log.
 *
 * Privacy by default — sensitive keys (cookie, token, nonce, api_key, etc.)
 * are auto-redacted from context arrays, and inline secrets in free-text
 * messages (Google API keys, Bearer tokens, key=…/nonce=…/token=… query
 * params) are stripped too. User agents are NOT collected.
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Logs {

	const OPTION_KEY    = 'blaminhor_essentials_logs_settings';
	const RETENTION_SEC = DAY_IN_SECONDS;          // 24 h rolling window
	const MAX_BYTES     = 5 * 1024 * 1024;          // 5 MB hard safety cap
	const ROTATE_AFTER  = 64 * 1024;                // start trimming once file > 64 KB

	/** @var Blaminhor_Essentials_Logs|null */
	private static $instance = null;

	/** @var array|null Cached settings (so we don't hit the option N times per request). */
	private $settings = null;

	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	private function __construct() {}

	public function get_default_settings() {
		return array(
			'enabled'   => false,
			'modules'   => array(), // empty = all modules collected
			'min_level' => 'warning',
		);
	}

	public function get_settings() {
		if ( null === $this->settings ) {
			$stored         = get_option( self::OPTION_KEY, array() );
			$this->settings = wp_parse_args( is_array( $stored ) ? $stored : array(), $this->get_default_settings() );
		}
		return $this->settings;
	}

	public function update_settings( $new ) {
		$defaults = $this->get_default_settings();
		$clean    = array();

		$clean['enabled'] = ! empty( $new['enabled'] );
		$clean['modules'] = ( isset( $new['modules'] ) && is_array( $new['modules'] ) )
			? array_values( array_unique( array_map( 'sanitize_key', $new['modules'] ) ) )
			: array();
		$clean['min_level'] = ( isset( $new['min_level'] ) && in_array( (string) $new['min_level'], array( 'debug', 'info', 'warning', 'error' ), true ) )
			? (string) $new['min_level']
			: $defaults['min_level'];

		update_option( self::OPTION_KEY, $clean, false );
		$this->settings = $clean;
		return $clean;
	}

	public function is_enabled() {
		$s = $this->get_settings();
		return ! empty( $s['enabled'] );
	}

	public function is_module_collected( $module_id ) {
		$s = $this->get_settings();
		if ( empty( $s['modules'] ) ) {
			return true; // empty list = collect every module
		}
		return in_array( (string) $module_id, $s['modules'], true );
	}

	public function is_level_collected( $level ) {
		$s     = $this->get_settings();
		$order = array( 'debug' => 0, 'info' => 1, 'warning' => 2, 'error' => 3 );
		$cur   = isset( $order[ $level ] ) ? $order[ $level ] : 1;
		$min   = isset( $order[ $s['min_level'] ] ) ? $order[ $s['min_level'] ] : 2;
		return $cur >= $min;
	}

	/**
	 * Record an event. Silent no-op when logging is disabled or filtered out.
	 * Always safe to call from anywhere.
	 *
	 * @param string $module_id Module slug ("cache", "smtp", …) or "core".
	 * @param string $level     debug | info | warning | error
	 * @param string $event     Short machine-readable identifier (snake_case).
	 * @param string $message   Human-readable message.
	 * @param array  $context   Optional structured context. Sensitive values redacted.
	 */
	public function record( $module_id, $level, $event, $message, $context = array() ) {
		if ( ! $this->is_enabled() ) {
			return;
		}
		if ( ! $this->is_module_collected( $module_id ) ) {
			return;
		}
		if ( ! $this->is_level_collected( $level ) ) {
			return;
		}

		$entry = array(
			'ts'      => gmdate( 'c' ),
			'module'  => sanitize_key( $module_id ),
			'level'   => (string) $level,
			'event'   => sanitize_key( $event ),
			'message' => $this->scrub_string( (string) $message ),
			'context' => $this->redact( (array) $context ),
		);

		$line = wp_json_encode( $entry );
		if ( false === $line ) {
			return;
		}

		$path = $this->log_file_path();
		if ( '' === $path ) {
			return;
		}

		$this->maybe_rotate();

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $path, $line . "\n", FILE_APPEND | LOCK_EX );
	}

	/**
	 * Read events currently in the rolling 24 h window, optionally filtered.
	 *
	 * @param array $filters keys: module (slug), level, search (text).
	 * @return array list of decoded events.
	 */
	public function read( $filters = array() ) {
		$path = $this->log_file_path();
		if ( '' === $path || ! is_file( $path ) ) {
			return array();
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$contents = @file_get_contents( $path );
		if ( false === $contents || '' === $contents ) {
			return array();
		}
		$cutoff = time() - self::RETENTION_SEC;
		$events = array();
		foreach ( explode( "\n", $contents ) as $raw ) {
			$raw = trim( $raw );
			if ( '' === $raw ) {
				continue;
			}
			$obj = json_decode( $raw, true );
			if ( ! is_array( $obj ) || ! isset( $obj['ts'] ) ) {
				continue;
			}
			$ts = strtotime( (string) $obj['ts'] );
			if ( ! $ts || $ts < $cutoff ) {
				continue;
			}
			if ( ! empty( $filters['module'] ) && (string) $obj['module'] !== (string) $filters['module'] ) {
				continue;
			}
			if ( ! empty( $filters['level'] ) && (string) $obj['level'] !== (string) $filters['level'] ) {
				continue;
			}
			if ( ! empty( $filters['search'] ) ) {
				$needle = strtolower( (string) $filters['search'] );
				$hay    = strtolower( (string) ( $obj['message'] ?? '' ) . ' ' . (string) ( $obj['event'] ?? '' ) );
				if ( false === strpos( $hay, $needle ) ) {
					continue;
				}
			}
			$events[] = $obj;
		}
		return $events;
	}

	public function clear() {
		$path = $this->log_file_path();
		if ( '' !== $path && is_file( $path ) ) {
			@unlink( $path );
		}
	}

	/**
	 * Build the export payload (used by Copy + Download).
	 */
	public function export_json( $filters = array() ) {
		return array(
			'plugin'      => 'blaminhor-essentials',
			'version'     => defined( 'BLAMINHOR_ESSENTIALS_VERSION' ) ? BLAMINHOR_ESSENTIALS_VERSION : 'unknown',
			'site'        => array(
				'wp_version' => get_bloginfo( 'version' ),
				'php'        => PHP_VERSION,
				'language'   => get_locale(),
				'home_url'   => home_url( '/' ),
			),
			'exported_at' => gmdate( 'c' ),
			'window_h'    => 24,
			'filters'     => array(
				'module' => isset( $filters['module'] ) ? (string) $filters['module'] : '',
				'level'  => isset( $filters['level'] ) ? (string) $filters['level'] : '',
				'search' => isset( $filters['search'] ) ? (string) $filters['search'] : '',
			),
			'events'      => $this->read( $filters ),
		);
	}

	/* ---------------------------------------------------------------- */
	/*  Internals                                                        */
	/* ---------------------------------------------------------------- */

	public function log_file_path() {
		$upload = wp_upload_dir();
		if ( ! empty( $upload['error'] ) ) {
			return '';
		}
		$dir = trailingslashit( $upload['basedir'] ) . 'blaminhor-essentials/logs';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
			$this->protect_dir( $dir );
		}
		return $dir . '/events.jsonl';
	}

	private function protect_dir( $dir ) {
		// Empty index.html so listings don't leak.
		$idx = $dir . '/index.html';
		if ( ! file_exists( $idx ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $idx, '' );
		}
		// Apache/LiteSpeed deny.
		$ht = $dir . '/.htaccess';
		if ( ! file_exists( $ht ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $ht, "Order allow,deny\nDeny from all\n" );
		}
	}

	private function maybe_rotate() {
		$path = $this->log_file_path();
		if ( '' === $path || ! is_file( $path ) ) {
			return;
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.filesize_filesize
		$size = @filesize( $path );
		if ( false === $size || $size < self::ROTATE_AFTER ) {
			return; // cheap path: skip until the file actually grows
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$contents = @file_get_contents( $path );
		if ( false === $contents ) {
			return;
		}
		$cutoff = time() - self::RETENTION_SEC;
		$kept   = array();
		foreach ( explode( "\n", $contents ) as $raw ) {
			$raw = trim( $raw );
			if ( '' === $raw ) {
				continue;
			}
			$obj = json_decode( $raw, true );
			if ( ! is_array( $obj ) || ! isset( $obj['ts'] ) ) {
				continue;
			}
			$ts = strtotime( (string) $obj['ts'] );
			if ( $ts && $ts >= $cutoff ) {
				$kept[] = $raw;
			}
		}
		$rebuilt = empty( $kept ) ? '' : ( implode( "\n", $kept ) . "\n" );
		// Hard cap — runaway logger must never fill the disk.
		if ( strlen( $rebuilt ) > self::MAX_BYTES ) {
			$rebuilt = substr( $rebuilt, -self::MAX_BYTES );
			$nl_pos  = strpos( $rebuilt, "\n" );
			if ( false !== $nl_pos ) {
				$rebuilt = substr( $rebuilt, $nl_pos + 1 );
			}
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		@file_put_contents( $path, $rebuilt, LOCK_EX );
	}

	/**
	 * Recursively redact obvious secrets from a context structure.
	 */
	private function redact( $value ) {
		if ( is_array( $value ) ) {
			$out = array();
			foreach ( $value as $k => $v ) {
				if ( $this->is_sensitive_key( strtolower( (string) $k ) ) ) {
					$out[ $k ] = '[redacted]';
					continue;
				}
				$out[ $k ] = $this->redact( $v );
			}
			return $out;
		}
		if ( is_string( $value ) ) {
			return $this->scrub_string( $value );
		}
		if ( is_scalar( $value ) || is_null( $value ) ) {
			return $value;
		}
		return '[unsupported type]';
	}

	private function is_sensitive_key( $key_lower ) {
		$needles = array(
			'cookie',
			'authorization',
			'auth',
			'token',
			'nonce',
			'password',
			'secret',
			'api_key',
			'apikey',
			'session',
		);
		foreach ( $needles as $needle ) {
			if ( false !== strpos( $key_lower, $needle ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Scrub strings for inline secrets the calling site might have included
	 * in a free-text message. Conservative — better to leak a bit of context
	 * than to spam-redact every legitimate substring.
	 */
	private function scrub_string( $s ) {
		// Google API keys (AIza…).
		$s = preg_replace( '/AIza[0-9A-Za-z\-_]{20,}/', 'AIza[redacted]', (string) $s );
		// Bearer tokens.
		$s = preg_replace( '/Bearer\s+[A-Za-z0-9\-_\.]{16,}/i', 'Bearer [redacted]', $s );
		// "key=" / "nonce=" / "token=" / "password=" / "secret=" query params.
		$s = preg_replace( '/(key|nonce|token|password|secret)=([^&\s"\']+)/i', '$1=[redacted]', $s );
		return $s;
	}
}
