<?php
/**
 * Error Log Reader — streams the WordPress debug log, parses PHP error entries,
 * and writes deduplicated rows to wp_conflict_radar_logs.
 *
 * Reads incrementally via fopen()/fseek() from the conflict_radar_last_log_pointer
 * option — never file() or file_get_contents() (debug logs can reach hundreds of MB).
 *
 * @package ConflictRadar\Monitor
 */

declare( strict_types=1 );

namespace ConflictRadar\Monitor;

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

class ErrorLogReader {

	/**
	 * A line that OPENS with this timestamp is a boundary: it flushes any record
	 * currently buffered. The trailing "(?:[^\]]*)" absorbs the zone (" UTC",
	 * " +00:00", or nothing) up to the closing bracket.
	 */
	private const RE_TIMESTAMP = '/^\[\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}(?:[^\]]*)\]/';

	/**
	 * A boundary line that is specifically a PHP error header. The error label is
	 * WHITELISTED to the known PHP types — not a generic "(.+?):". Some SAPIs
	 * (notably WP-CLI) prefix every stack-trace line with its own "[ts] PHP " too,
	 * and a frame whose path contains "(NN) : eval()'d code" would otherwise supply
	 * a spurious ":<space>" and be mis-read as a header, emitting a junk row.
	 * Group 1 = timestamp, group 2 = error label, group 3 = message (+ location).
	 */
	private const RE_PHP_HEADER = '/^\[(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2})(?:[^\]]*)\] PHP (Parse error|Recoverable fatal error|Catchable fatal error|Fatal error|Warning|Notice|Deprecated|Strict Standards):\s+(.*)$/';

	/**
	 * Wire hooks. Called from Plugin::run() on plugins_loaded.
	 * - conflict_radar_scan_logs: hourly cron (scheduled by the Activator).
	 * - conflict_radar_event_recorded: the Phase 2 UpdateTracker seam — F2 says
	 *   scan immediately after any update event. Cheap: the pointer means only
	 *   new bytes are read.
	 */
	public function register(): void {
		add_action( 'conflict_radar_scan_logs', array( $this, 'scan' ) );
		add_action( 'conflict_radar_event_recorded', array( $this, 'scan' ) );
	}

	/**
	 * Full parse cycle: resolve the log path, stream new bytes since the last
	 * scan, parse entries, and persist deduplicated rows.
	 */
	public function scan(): void {
		$path = $this->resolveLogPath();
		if ( null === $path ) {
			return; // Logging disabled or file missing — bail cleanly (admin notice is Phase 4).
		}

		clearstatcache( true, $path );
		$pointer = (int) get_option( 'conflict_radar_last_log_pointer', 0 );
		$size    = (int) filesize( $path );

		// Truncation/rotation guard: file shrank, so the old offset is invalid.
		if ( $size < $pointer ) {
			$pointer = 0;
		}
		if ( $size <= $pointer ) {
			return; // Nothing new since last scan.
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming read with fseek/ftell byte-offset tracking (conflict_radar_last_log_pointer); WP_Filesystem has no seek primitive and its get_contents() would load the entire (potentially hundreds-of-MB) debug.log into memory, which docs/03-ARCHITECTURE.md explicitly forbids.
		$handle = fopen( $path, 'r' );
		if ( false === $handle ) {
			return;
		}
		fseek( $handle, $pointer );

		// Buffer the record currently being assembled. A boundary flushes it;
		// continuation lines extend its raw_entry. See parse rules below.
		$open = null;

		while ( ( $raw = fgets( $handle ) ) !== false ) {
			$line = rtrim( $raw, "\r\n" );
			if ( '' === $line ) {
				continue;
			}

			// Leading timestamp = BOUNDARY: flush whatever was open first.
			if ( 1 === preg_match( self::RE_TIMESTAMP, $line ) ) {
				if ( null !== $open ) {
					$this->insertLog( $open );
					$open = null;
				}

				// A PHP error header starts a new record; a timestamped non-PHP
				// line (app-level error_log chatter) is discarded — no row.
				$record = $this->parseHeader( $line );
				if ( null !== $record ) {
					$open = $record;
				}
				continue;
			}

			// No leading timestamp = CONTINUATION (stack trace, "thrown in...",
			// "#0 ..."). Append to the open record, or discard if none is open
			// (incremental scan may start mid-trace whose header was already read).
			if ( null !== $open ) {
				$open['raw_entry'] .= "\n" . $line;
			}
		}

		// Flush the trailing record at EOF.
		if ( null !== $open ) {
			$this->insertLog( $open );
		}

		update_option( 'conflict_radar_last_log_pointer', ftell( $handle ) );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the streamed file handle opened above for fseek/ftell pointer-based reading.
		fclose( $handle );
	}

	/**
	 * Three-state log path detection (F2). Caches the result in
	 * conflict_radar_log_path. Returns null when logging is disabled or the file
	 * is absent — callers must bail in that case.
	 */
	private function resolveLogPath(): ?string {
		$path = '';

		if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) {
			// State 1: explicit custom path.
			$path = WP_DEBUG_LOG;
		} elseif (
			( defined( 'WP_DEBUG_LOG' ) && true === WP_DEBUG_LOG )
			|| ( ! defined( 'WP_DEBUG_LOG' ) && defined( 'WP_DEBUG' ) && WP_DEBUG )
		) {
			// State 2: boolean-true logging → default path.
			$path = WP_CONTENT_DIR . '/debug.log';
		}

		// State 3: disabled (incl. WP_DEBUG_LOG === false) or file missing.
		if ( '' === $path || ! file_exists( $path ) ) {
			update_option( 'conflict_radar_log_path', '' );
			return null;
		}

		update_option( 'conflict_radar_log_path', $path );
		return $path;
	}

	/**
	 * Parse a PHP error header line into a record skeleton. Returns null if the
	 * line is timestamped but not a PHP error (caller discards it).
	 *
	 * @return array<string,mixed>|null
	 */
	private function parseHeader( string $line ): ?array {
		if ( 1 !== preg_match( self::RE_PHP_HEADER, $line, $m ) ) {
			return null;
		}

		$timestamp = $m[1];
		$label     = $m[2];
		$message   = $m[3];
		$file      = '';
		$line_no   = 0;

		// Split a trailing " in <file> on line <N>" off the message. Greedy so it
		// anchors on the LAST occurrence (the real location suffix at the end).
		if ( 1 === preg_match( '/^(.*) in (.+) on line (\d+)\s*$/', $message, $loc ) ) {
			$message  = rtrim( $loc[1] );
			// PHP reports eval'd / regex-created code as "<file>(<line>) : eval()'d code".
			// Strip that marker so error_file stays a real path, not "...php(39) : eval()'d code".
			$file_raw = preg_replace( '/\(\d+\)\s*:\s*eval\(\)\'d code.*$/', '', $loc[2] );
			$file     = $this->relativePath( $file_raw );
			$line_no  = (int) $loc[3];
		}

		return array(
			'error_type'    => $this->mapErrorType( $label ),
			'error_message' => $message,
			'error_file'    => $file,
			'error_line'    => $line_no,
			'raw_entry'     => $line, // Continuation lines append to this.
			'logged_at'     => $this->toMysqlUtc( $timestamp ),
		);
	}

	/**
	 * Map a PHP error label to the schema's error_type enum. Unknown ⇒ notice.
	 * "Parse error" and "Fatal" are checked first so they win over substrings.
	 */
	private function mapErrorType( string $label ): string {
		$label = strtolower( $label );

		if ( str_contains( $label, 'parse error' ) ) {
			return 'parse';
		}
		if ( str_contains( $label, 'fatal' ) ) {
			return 'fatal';
		}
		if ( str_contains( $label, 'deprecated' ) ) {
			return 'deprecated';
		}
		if ( str_contains( $label, 'warning' ) ) {
			return 'warning';
		}
		if ( str_contains( $label, 'notice' ) ) {
			return 'notice';
		}
		return 'notice';
	}

	/**
	 * Make a file path ABSPATH-relative. Normalizes both sides to forward slashes
	 * FIRST (Windows/LocalWP mixes "C:\..." and "C:/..."), so the strip is
	 * separator-agnostic. Capped to the varchar(500) column width.
	 */
	private function relativePath( string $file ): string {
		$file    = wp_normalize_path( trim( $file ) );
		$abspath = wp_normalize_path( ABSPATH );

		if ( '' !== $abspath && str_starts_with( $file, $abspath ) ) {
			$file = substr( $file, strlen( $abspath ) );
		}

		return substr( $file, 0, 500 );
	}

	/**
	 * Convert a "30-May-2026 05:29:32" log timestamp to MySQL UTC datetime.
	 * The WP debug log writes UTC; logged_at is specified as UTC in the schema.
	 */
	private function toMysqlUtc( string $timestamp ): string {
		$dt = \DateTime::createFromFormat( 'd-M-Y H:i:s', $timestamp, new \DateTimeZone( 'UTC' ) );
		return $dt ? $dt->format( 'Y-m-d H:i:s' ) : current_time( 'mysql', true );
	}

	/**
	 * Persist one parsed entry. Dedup is handled entirely by the UNIQUE KEY on
	 * log_hash + INSERT IGNORE — a single index lookup, no SELECT-then-INSERT.
	 *
	 * @param array<string,mixed> $r Record from parseHeader(), raw_entry possibly extended.
	 */
	private function insertLog( array $r ): void {
		global $wpdb;

		$log_hash = md5( $r['logged_at'] . $r['error_file'] . $r['error_line'] );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table (wp_conflict_radar_*); WP options API / object cache not applicable to dedicated event/reporting data.
		$inserted = $wpdb->query(
			$wpdb->prepare(
				"INSERT IGNORE INTO {$wpdb->prefix}conflict_radar_logs
				 (error_type, error_message, error_file, error_line, raw_entry, log_hash, logged_at)
				 VALUES (%s, %s, %s, %d, %s, %s, %s)",
				$r['error_type'],
				$r['error_message'],
				$r['error_file'],
				$r['error_line'],
				$r['raw_entry'],
				$log_hash,
				$r['logged_at']
			)
		);

		// SEAM #1: announce a GENUINELY-NEW error so alerting (Pro) can react.
		// INSERT IGNORE returns 1 affected row on a real insert and 0 on a dedup
		// skip (duplicate log_hash) — firing on a skip would re-alert the same error
		// on every hourly scan (spam), so gate strictly on a fresh insert.
		if ( 1 === (int) $inserted ) {
			/**
			 * Fires once per newly-logged error (never on a dedup skip).
			 *
			 * @param array $error_data log_id, error_type, error_message, error_file, error_line, logged_at.
			 */
			do_action(
				'conflict_radar_error_logged',
				array(
					'log_id'        => (int) $wpdb->insert_id,
					'error_type'    => (string) $r['error_type'],
					'error_message' => (string) $r['error_message'],
					'error_file'    => (string) $r['error_file'],
					'error_line'    => (int) $r['error_line'],
					'logged_at'     => (string) $r['logged_at'],
				)
			);
		}
	}
}
