<?php
/**
 * Shared logging component - file-based logger. Structured lines (event name +
 * JSON context), five levels (off / error / warning / info / debug),
 * size-capped rotation with one .1 backup.
 *
 * Log location is the CROSS-PLUGIN STANDARD (see _standards CONVENTIONS.md
 * "Logging"): uploads/<slug>/<slug>.log - the slug names the product wherever
 * the file travels (support inboxes, downloads), with no internal-abbreviation
 * dependency.
 *
 * Decoupled from any consuming plugin: the active log level is read through an
 * injected callable (config 'log_level_getter') so it stays a plugin setting,
 * and the directory/file name derive from the boot slug. Sensitive values are
 * redacted before writing: any context key containing 'token' is masked, and
 * consumer-specific patterns come from config 'redact_patterns'.
 *
 * Lineage: extracted from the shared render engine's class-engine-logger.php
 * (itself seeded from SSAA's class-logger.php); the warning level and
 * config-driven redaction were added at extraction (2026-07-05).
 *
 * CANONICAL SOURCE - edit here in _shared/shared-logging/src/, re-run sync.sh.
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Logger {

	/** Rotate when the log exceeds this many bytes. */
	const MAX_FILE_SIZE = 512000; // 500 KB

	/** Level ranks - higher = more verbose. */
	const LEVELS = array(
		'off'     => 0,
		'error'   => 1,
		'warning' => 2,
		'info'    => 3,
		'debug'   => 4,
	);

	public static function error( $event, array $context = array() ) {
		self::log( 'error', $event, $context );
	}

	public static function warning( $event, array $context = array() ) {
		self::log( 'warning', $event, $context );
	}

	public static function info( $event, array $context = array() ) {
		self::log( 'info', $event, $context );
	}

	public static function debug( $event, array $context = array() ) {
		self::log( 'debug', $event, $context );
	}

	/**
	 * Write one structured log line if the configured level allows it.
	 *
	 * @param string $level   error|warning|info|debug.
	 * @param string $event   Snake_case event name (grep target).
	 * @param array  $context Context dict - redacted before writing.
	 */
	public static function log( $level, $event, array $context = array() ) {
		$configured = self::configured_level();

		if ( ! isset( self::LEVELS[ $level ] ) || ! isset( self::LEVELS[ $configured ] ) ) {
			return;
		}
		if ( 0 === self::LEVELS[ $configured ] || self::LEVELS[ $level ] > self::LEVELS[ $configured ] ) {
			return;
		}

		$dir = self::log_dir();
		if ( '' === $dir ) {
			return;
		}

		$file = self::log_file();
		self::maybe_rotate( $file );

		// Line format is the cross-plugin standard (see _standards
		// CONVENTIONS.md "Admin UI standards"):
		//   [YYYY-MM-DD HH:MM:SS UTC] [LEVEL] [scope] event {context}
		// scope is 'global', 'post:ID', or a short ref string for non-post
		// targets (e.g. synthesized archive URLs).
		$scope = 'global';
		if ( isset( $context['post_id'] ) ) {
			$scope = 'post:' . absint( $context['post_id'] );
			unset( $context['post_id'] );
		} elseif ( isset( $context['ref'] ) && is_string( $context['ref'] ) && '' !== $context['ref'] ) {
			$scope = self::scope_from_ref( (string) $context['ref'] );
			unset( $context['ref'] );
		}

		// JSON_UNESCAPED_SLASHES keeps URLs copy/paste-friendly (https://… not
		// https:\/\/…); JSON_UNESCAPED_UNICODE keeps non-ASCII readable.
		$context_json = empty( $context ) ? '' : ' ' . wp_json_encode( self::redact( $context ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		$line = sprintf(
			"[%s UTC] [%s] [%s] %s%s\n",
			gmdate( 'Y-m-d H:i:s' ),
			strtoupper( $level ),
			$scope,
			self::redact_string( (string) $event ),
			$context_json
		);

		// Atomic append. file_put_contents with FILE_APPEND|LOCK_EX is the
		// accepted hot-path pattern for per-event logging where WP_Filesystem
		// is the wrong abstraction (see _standards/docs/CONVENTIONS.md
		// "Filesystem operations").
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- hot-path append-only logger; WP_Filesystem has no atomic append.
		file_put_contents( $file, $line, FILE_APPEND | LOCK_EX );
	}

	/**
	 * The active log level. Read through the injected callable so it tracks
	 * the plugin's live setting; falls back to a static config value, then
	 * 'info'.
	 *
	 * @return string
	 */
	private static function configured_level() {
		$getter = SSAA_Log::config( 'log_level_getter' );
		if ( is_callable( $getter ) ) {
			return (string) call_user_func( $getter );
		}
		return (string) SSAA_Log::config( 'log_level', 'info' );
	}

	/**
	 * Current log file path ('' if the directory cannot be created).
	 *
	 * @return string
	 */
	public static function log_file() {
		$dir = self::log_dir();
		return '' === $dir ? '' : $dir . SSAA_Log::slug() . '.log';
	}

	/**
	 * Current log size in bytes (0 when absent).
	 *
	 * @return int
	 */
	public static function size() {
		$file = self::log_file();
		return ( '' !== $file && file_exists( $file ) ) ? (int) filesize( $file ) : 0;
	}

	/**
	 * Last N lines of the log, newest last.
	 *
	 * @param int $max_lines Lines to return.
	 * @return string
	 */
	public static function tail( $max_lines = 200 ) {
		$contents = self::contents();
		if ( '' === $contents ) {
			return '';
		}
		$lines = explode( "\n", rtrim( $contents, "\n" ) );
		return implode( "\n", array_slice( $lines, -absint( $max_lines ) ) );
	}

	/**
	 * Full log contents (current file only, not the .1 backup).
	 *
	 * @return string
	 */
	public static function contents() {
		$file = self::log_file();
		if ( '' === $file || ! file_exists( $file ) ) {
			return '';
		}

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		global $wp_filesystem;

		return $wp_filesystem ? (string) $wp_filesystem->get_contents( $file ) : '';
	}

	/**
	 * Delete the log file and its rotation backup.
	 */
	public static function clear() {
		$file = self::log_file();
		if ( '' === $file ) {
			return;
		}
		if ( file_exists( $file ) ) {
			wp_delete_file( $file );
		}
		if ( file_exists( $file . '.1' ) ) {
			wp_delete_file( $file . '.1' );
		}
	}

	/**
	 * Ensure and return the protected log directory (trailing slash):
	 * uploads/<slug>/ - the cross-plugin standard location.
	 *
	 * @return string
	 */
	private static function log_dir() {
		// Memoised per request: wp_upload_dir() runs its filter chain on every
		// call (expensive with offload/media plugins hooked) and the path
		// cannot change mid-request. Under an index-crawl-style burst this
		// method runs per logged event.
		static $memo = null;
		if ( null !== $memo ) {
			return $memo;
		}

		$upload = wp_upload_dir();
		if ( ! empty( $upload['error'] ) ) {
			return ''; // Not memoised - uploads may become usable later in the request.
		}

		$dir = trailingslashit( $upload['basedir'] ) . SSAA_Log::slug() . '/';

		if ( ! is_dir( $dir ) ) {
			if ( ! wp_mkdir_p( $dir ) ) {
				return '';
			}
			// Block direct HTTP access (Apache 2.2 AND 2.4 syntax - a bare
			// "Deny from all" 500s every request into the directory on 2.4
			// without mod_access_compat) and directory listings. A consumer
			// that must serve OTHER files from this directory (e.g. static
			// thumbnails) may overwrite this default with a narrower policy;
			// the component never rewrites an existing guard file.
			$guard = "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n"
				. "<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n";
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- one-time guard files at directory creation.
			file_put_contents( $dir . '.htaccess', $guard );
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- one-time guard files at directory creation.
			file_put_contents( $dir . 'index.html', '' );
		}

		$memo = $dir;
		return $dir;
	}

	/**
	 * Size-capped rotation: current file becomes .1 (replacing any previous
	 * .1), a fresh file starts on the next write.
	 *
	 * @param string $file Log file path.
	 */
	private static function maybe_rotate( $file ) {
		if ( ! file_exists( $file ) || filesize( $file ) < self::MAX_FILE_SIZE ) {
			return;
		}
		$backup = $file . '.1';
		if ( file_exists( $backup ) ) {
			wp_delete_file( $backup );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- hot-path log rotation; the rename rule accepts annotation (see _standards/docs/CONVENTIONS.md).
		rename( $file, $backup );
	}

	/**
	 * A compact log scope from a target ref. 'post:123' passes through;
	 * 'url:<sha1>' is shortened to 'url:<first 8>'.
	 *
	 * @param string $ref Target ref ('post:ID' or 'url:hash').
	 * @return string
	 */
	private static function scope_from_ref( $ref ) {
		if ( 0 === strpos( $ref, 'url:' ) ) {
			return 'url:' . substr( substr( $ref, 4 ), 0, 8 );
		}
		return $ref;
	}

	/**
	 * Redact sensitive values from context before writing. Two layers:
	 * any context KEY containing a sensitive word (token / secret / password
	 * by default, extendable via config 'redact_keys') is masked outright,
	 * and every string VALUE runs through the consumer's config
	 * 'redact_patterns' (regex => replacement) - redaction by construction,
	 * so no call site has to remember to sanitise. Call sites that WANT a
	 * safe trace of a credential log a fingerprint under a non-matching key
	 * (the cross-plugin convention: first 8 + last 4 + length).
	 *
	 * @param array $context Raw context.
	 * @return array
	 */
	private static function redact( array $context ) {
		$sensitive = array_merge(
			array( 'token', 'secret', 'password' ),
			array_map( 'strval', (array) SSAA_Log::config( 'redact_keys', array() ) )
		);
		foreach ( $context as $key => $value ) {
			if ( is_string( $value ) ) {
				foreach ( $sensitive as $needle ) {
					if ( '' !== $needle && false !== stripos( (string) $key, $needle ) ) {
						$context[ $key ] = '[redacted]';
						break;
					}
				}
			}
			if ( is_string( $value ) ) {
				$context[ $key ] = self::redact_string( $context[ $key ] );
			}
			if ( is_array( $value ) ) {
				$context[ $key ] = self::redact( $value );
			}
		}
		return $context;
	}

	/**
	 * Apply the consumer's redact_patterns to one string.
	 *
	 * @param string $value Raw string.
	 * @return string
	 */
	private static function redact_string( $value ) {
		foreach ( (array) SSAA_Log::config( 'redact_patterns', array() ) as $pattern => $replacement ) {
			$redacted = preg_replace( (string) $pattern, (string) $replacement, $value );
			if ( null !== $redacted ) {
				$value = $redacted;
			}
		}
		return $value;
	}
}
