<?php
/**
 * Server-side changelog parser for the Changelog tab.
 *
 * Reads `readme.txt` at the plugin root (filterable) and returns the
 * structured shape consumed by GET /blocksify/v1/changelog. Single source
 * of regex truth — JS never reparses (SPEC §8.4 / §5.4).
 *
 * Parser contract (SPEC §5.4):
 *   - Section header: `== Changelog ==`            (readme.txt convention)
 *   - Version header: `= 1.2.3 =`                  (date NOT inline)
 *   - Pre-release:    `= 1.2.3-beta.1 =`
 *   - Date line:      `*Release Date - May 18, 2026*` (raw verbatim, format-agnostic)
 *   - Items:          `* Added: text` (inline category prefix)
 *                     `* text`        (uncategorized)
 *
 * Item shape emitted: `{ category?, text }` — `category` is the lowercase
 * prefix when the source line had one (`added` / `changed` / `fixed` /
 * `removed` / `deprecated` / `security`); absent for plain lines.
 *
 * Unknown lines under a release header are preserved verbatim as
 * uncategorized items so drift doesn't drop content.
 *
 * @package Blocksify
 */

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

/**
 * Changelog parser. Stateless; methods are static.
 */
class Blocksify_Dashboard_Changelog {

	/**
	 * Parse the changelog file and return the REST payload shape.
	 * Missing file → `{ releases: [] }`.
	 *
	 * @return array { releases: array }
	 */
	public static function parse() {
		$path = self::path();

		$contents = self::read_file( $path );
		if ( '' === $contents ) {
			return array( 'releases' => array() );
		}

		// readme.txt convention: changelog lives under `== Changelog ==`.
		// Slice that section out before walking; falls through to the full
		// file when the marker is absent (e.g. a bespoke changelog.txt).
		$contents = self::extract_changelog_section( $contents );

		$releases = self::parse_contents( $contents );

		$current_version = defined( 'BLOCKSIFY_VERSION' ) ? BLOCKSIFY_VERSION : '';
		$marked          = false;
		foreach ( $releases as &$release ) {
			$release['current'] = ! $marked && $current_version !== '' && $release['version'] === $current_version;
			if ( $release['current'] ) {
				$marked = true;
			}
		}
		unset( $release );

		return array( 'releases' => $releases );
	}

	/**
	 * Resolve the changelog file path. Filterable so multi-channel changelogs
	 * (Pro, beta) or alternate file names can override. SPEC §11.2.
	 *
	 * @return string Absolute path.
	 */
	public static function path() {
		$default = defined( 'BLOCKSIFY_PATH' ) ? BLOCKSIFY_PATH . 'readme.txt' : '';
		return (string) apply_filters( 'blocksify_dashboard_changelog_path', $default );
	}

	/**
	 * Read the file via WP_Filesystem when available, fall back to a direct
	 * `file_get_contents()` so the parser stays usable before the Filesystem
	 * API is bootstrapped (early REST requests).
	 *
	 * @param string $path Absolute path.
	 * @return string File contents, or '' on failure.
	 */
	private static function read_file( $path ) {
		if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) {
			return '';
		}

		global $wp_filesystem;
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		if ( WP_Filesystem() && $wp_filesystem ) {
			$data = $wp_filesystem->get_contents( $path );
			if ( false !== $data ) {
				return (string) $data;
			}
		}

		$data = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		return false === $data ? '' : (string) $data;
	}

	/**
	 * Slice the `== Changelog ==` section out of a readme.txt body. The
	 * section runs until the next `== Heading ==` marker or EOF. When the
	 * marker is absent (bespoke changelog.txt), returns the input as-is.
	 *
	 * @param string $text Full file contents.
	 * @return string Section body (or the input when no marker).
	 */
	private static function extract_changelog_section( $text ) {
		if ( ! preg_match( '/^==\s*Changelog\s*==\s*$(.*?)(?=^==\s*\S|\Z)/sm', $text, $matches ) ) {
			return $text;
		}
		return trim( $matches[1] );
	}

	/**
	 * Walk the raw text line-by-line. State machine: outside-release →
	 * inside-release → outside on next version header.
	 *
	 * @param string $text Raw section body.
	 * @return array<int, array{version:string,date:string,items:array}>
	 */
	private static function parse_contents( $text ) {
		$releases   = array();
		$current    = null;
		$lines      = preg_split( '/\r\n|\r|\n/', $text );
		$version_re = '/^=\s*(\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?)\s*=$/';
		$date_re    = '/^\*\s*Release\s+Date\s*-\s*(.+?)\s*\*$/i';
		$item_re    = '/^[\*\-]\s+(.+)$/';
		$category_re = '/^(Added|New|Changed|Improved|Enhanced|Enhancement|Fixed|Fix|Updated|Update|Removed|Deprecated|Security)\s*:\s*(.+)$/i';

		foreach ( $lines as $line ) {
			$line    = rtrim( $line );
			$trimmed = trim( $line );

			if ( preg_match( $version_re, $trimmed, $matches ) ) {
				if ( null !== $current ) {
					$releases[] = $current;
				}
				$current = array(
					'version' => $matches[1],
					'date'    => '',
					'items'   => array(),
				);
				continue;
			}

			if ( null === $current || '' === $trimmed ) {
				continue;
			}

			if ( preg_match( $date_re, $trimmed, $matches ) ) {
				$current['date'] = trim( $matches[1] );
				continue;
			}

			if ( preg_match( $item_re, $trimmed, $matches ) ) {
				$text_part = trim( $matches[1] );
				if ( '' === $text_part ) {
					continue;
				}
				if ( preg_match( $category_re, $text_part, $cat_match ) ) {
					$current['items'][] = array(
						'category' => strtolower( $cat_match[1] ),
						'text'     => trim( $cat_match[2] ),
					);
				} else {
					$current['items'][] = array(
						'text' => $text_part,
					);
				}
			}
		}

		if ( null !== $current ) {
			$releases[] = $current;
		}

		return $releases;
	}
}
