<?php
// Standalone check for rest_tail_logs: php -d zend.assertions=1 tests/tail-logs.php
// No WordPress, no framework. It fails if the console can miss lines, repeat them, or show half of one.

function __( $s, $d = null ) { return $s; }
class WP_REST_Response {
	public $data;
	public function __construct( $data, $status = 200 ) { $this->data = $data; }
}
class FakeRequest {
	private $params;
	public function __construct( $params ) { $this->params = $params; }
	public function get_param( $k ) { return isset( $this->params[ $k ] ) ? $this->params[ $k ] : null; }
}
class FakeCore {
	public $path;
	public function is_debug() { return true; }
	public function get_logs_path() { return $this->path; }
}

// rest.php is a class body that needs the plugin bootstrapped, so lift the methods under test out
// of the file rather than loading it. If either moves, this stops finding it and fails.
$source = file_get_contents( __DIR__ . '/../classes/rest.php' );
$start = strpos( $source, 'function rest_tail_logs(' );
$end = strpos( $source, 'function rest_support_bundles(' );
assert( $start !== false && $end !== false && $end > $start, 'rest_tail_logs still lives in rest.php' );
$body = substr( $source, $start, $end - $start );
// Trim the trailing comment block that belongs to the next method.
$body = preg_replace( '/\n\t\/\/[^\n]*(\n\t\/\/[^\n]*)*\s*$/', "\n", $body );
eval( 'class Tailer { public $core; ' . $body . ' }' );

$log = tempnam( sys_get_temp_dir(), 'wpmc-tail-' );
$tailer = new Tailer();
$tailer->core = new FakeCore();
$tailer->core->path = $log;

function tail( $tailer, $offset ) {
	$data = $tailer->rest_tail_logs( new FakeRequest( array( 'offset' => $offset ) ) )->data['data'];
	$data['lines'] = array_map( function ( $e ) { return $e['text']; }, $data['events'] );
	return $data;
}

// A first poll starts at the end of the file: the console shows this scan, not the last one.
file_put_contents( $log, "2026-01-01 00:00:00: old line\n" );
$first = tail( $tailer, 0 );
// (offset 0 is the caller's "from the beginning"; the component sends no offset on its first poll,
// which arrives as 0 -- so this deliberately does return the backlog, and the component throws it away.)
assert( count( $first['lines'] ) === 1, 'reads from the given offset' );

// Nothing new means nothing repeated.
$idle = tail( $tailer, $first['offset'] );
assert( $idle['lines'] === array(), 'no new bytes, no lines' );
assert( $idle['offset'] === $first['offset'], 'offset holds steady' );

// New lines come back once, in order.
file_put_contents( $log, "line A\nline B\n", FILE_APPEND );
$next = tail( $tailer, $idle['offset'] );
assert( $next['lines'] === array( 'line A', 'line B' ), 'new lines once, in order: ' . implode( '|', $next['lines'] ) );

// A line still being written is not shown until it is whole, and is not skipped afterwards.
file_put_contents( $log, "half a li", FILE_APPEND );
$partial = tail( $tailer, $next['offset'] );
assert( $partial['lines'] === array(), 'a half-written line is withheld' );
file_put_contents( $log, "ne\n", FILE_APPEND );
$completed = tail( $tailer, $partial['offset'] );
assert( $completed['lines'] === array( 'half a line' ), 'and delivered whole once it lands: ' . implode( '|', $completed['lines'] ) );

// Rotation (or Clear Logs) replaces the file. A stale offset past the new end must not read from
// the middle of a line of the new file -- it restarts.
$stale = $completed['offset'];
file_put_contents( $log, "fresh file\n" );
$rotated = tail( $tailer, $stale );
assert( $rotated['lines'] === array( 'fresh file' ), 'rotation restarts the tail: ' . implode( '|', $rotated['lines'] ) );

// A caller that fell far behind skips ahead instead of dragging the whole backlog through a poll
// that repeats every second.
file_put_contents( $log, str_repeat( "a padded log line to burn some bytes\n", 12000 ) );
$behind = tail( $tailer, 0 );
$bytes = strlen( implode( "\n", $behind['lines'] ) );
assert( $bytes <= 128 * 1024, 'a backlog is capped, not streamed: ' . $bytes );
assert( $behind['offset'] === filesize( $log ), 'and still lands on the end of the file' );

// --- the log's vocabulary -------------------------------------------------
//
// The console draws from these fields, so a message reworded in core.php without its reader here
// shows up as an untyped "info" row rather than a block. That is what these pin.

function only( $tailer, $line ) {
	global $log;
	file_put_contents( $log, $line . "\n" );
	$data = $tailer->rest_tail_logs( new FakeRequest( array( 'offset' => 0 ) ) )->data['data'];
	assert( count( $data['events'] ) === 1, 'one line, one event' );
	return $data['events'][0];
}

$e = only( $tailer, '2026-09-21 03:26:30: ▶ wpmc_scan_once_smartslider3 (wpmc_scan_once)' );
assert( $e['kind'] === 'parser', 'a parser starting opens a block: ' . $e['kind'] );
assert( $e['group'] === 'wpmc_scan_once_smartslider3', 'and carries the group its events inherit' );
assert( $e['time'] === '03:26:30', 'the clock is kept, the date is not' );

$e = only( $tailer, '2026-09-21 03:26:53: 🐌 wpmc_scan_once_smartslider3 (wpmc_scan_once) took 51.20s, 90 queries' );
assert( $e['kind'] === 'timing' && $e['ms'] === 51200 && $e['queries'] === 90, 'a timing carries both numbers' );
assert( $e['group'] === 'wpmc_scan_once_smartslider3', 'and seals the block it belongs to' );

$e = only( $tailer, '2026-09-21 03:26:22: 🔍 Processing post ID: 42 | Type: product | Title: Ossenberg' );
assert( $e['kind'] === 'post' && $e['postId'] === 42 && $e['postType'] === 'product', 'a post is typed' );
assert( $e['text'] === 'Ossenberg', 'and titled' );
assert( $e['group'] === 'post-42', 'so its parsers nest under it' );

$e = only( $tailer, '2026-09-21 03:26:22: ✓ Completed post ID: 42 in 128.40ms' );
assert( $e['kind'] === 'post-done' && $e['ms'] === 128 && $e['group'] === 'post-42', 'a post closes its own block' );

// Announced before it runs, so the item that never finishes is still named.
$e = only( $tailer, '2026-09-21 03:26:22: 🗑 delete #4821 2020/10/unused.jpg' );
assert( $e['kind'] === 'cleanup' && $e['itemId'] === 4821, 'a cleanup item opens its own block' );
assert( $e['text'] === '2020/10/unused.jpg', 'and says which file: ' . $e['text'] );
assert( !isset( $e['ms'] ), 'with no duration yet, because it has not run' );

$e = only( $tailer, '2026-09-21 03:26:22: ✓ Cleaned #4821 in 240ms' );
assert( $e['kind'] === 'cleanup-done' && $e['ms'] === 240 && $e['group'] === 'cleanup-4821',
	'and its completion closes the same block' );

// A step is announced before it runs, so a delete that hangs still says which step it hung in.
$e = only( $tailer, '2026-09-21 03:26:22: → wp_update_post' );
assert( $e['kind'] === 'step' && $e['text'] === 'wp_update_post', 'a step announces itself: ' . $e['text'] );
assert( $e['group'] === null, 'and nests under whatever item is open' );

// An item a previous request already finished says so, instead of leaving a bare journal lookup.
$e = only( $tailer, '2026-09-21 03:26:22: ✓ #148 was already done' );
assert( $e['kind'] === 'ok' && strpos( $e['text'], '148' ) !== false, 'a replayed item is named: ' . $e['text'] );

// The warning has to reach the console as a warning, not as an untyped line lost in the stream.
$e = only( $tailer, '2026-09-21 03:26:22: ⚠ wp_delete_attachment took 128.0s for one file but ran only 94 database queries, so the time is not Media Cleaner.' );
assert( $e['kind'] === 'warn', 'the slow-cleanup warning is a warning: ' . $e['kind'] );
assert( strpos( $e['text'], 'not Media Cleaner' ) !== false, 'and keeps its wording' );

// The cleanup steps report themselves in the same shape a slow parser does.
$e = only( $tailer, '2026-09-21 03:26:22: 🐌 wp_update_post (cleanup) took 12.40s, 318 queries' );
assert( $e['kind'] === 'timing' && $e['ms'] === 12400 && $e['queries'] === 318, 'a cleanup step is timed' );
assert( $e['text'] === 'wp_update_post', 'and named: ' . $e['text'] );

$e = only( $tailer, '2026-09-21 03:26:22: 📊 Batch done: 5 items, 812 queries.' );
assert( $e['kind'] === 'batch' && $e['count'] === 5 && $e['queries'] === 812, 'a batch reports the real query cost' );

$e = only( $tailer, '2026-09-21 03:26:22: Flushing remaining 14 references to the database...' );
assert( $e['kind'] === 'refs' && $e['count'] === 14, 'a flush reports how many were written' );

// The console shows the sample muted under the post, so it has to survive the parse intact.
$e = only( $tailer, '2026-09-21 03:26:22: Flushing 25 references to the database... 2020/10/a.jpg, #142163 (+23 more)' );
assert( $e['kind'] === 'refs' && $e['count'] === 25, 'a sampled flush still reports its count' );
assert( $e['text'] === '2020/10/a.jpg, #142163 (+23 more)', 'and names what it wrote: ' . $e['text'] );

$e = only( $tailer, '2026-09-21 03:25:26: 🏁 Extracting refs from content...' );
assert( $e['kind'] === 'phase' && $e['text'] === 'Extracting refs from content', 'a phase is a separator' );

$e = only( $tailer, '2026-09-21 03:26:53: 😵 Timeout! Some info for debug:' );
assert( $e['kind'] === 'error', 'a timeout is an error, not a line of text' );

$e = only( $tailer, '2026-09-21 03:26:53: wpmc_scan_once_x attempted an unbounded database query. Its parser should use pagination.' );
assert( $e['kind'] === 'warn', 'the query guard is a warning' );

// Anything unrecognised still reaches the console rather than being silently dropped.
$e = only( $tailer, '2026-09-21 03:26:53: Something nobody wrote a pattern for' );
assert( $e['kind'] === 'info' && $e['text'] === 'Something nobody wrote a pattern for', 'unknown lines survive' );

// A line with no timestamp (the log writes bare separators) must not crash the parser.
$e = only( $tailer, 'bare line' );
assert( $e['kind'] === 'info' && $e['time'] === null, 'a line without a timestamp is still an event' );

unlink( $log );
echo "ok\n";
