<?php
/**
 * Header & Footer Code Module
 *
 * Injects custom HTML/JS/CSS into wp_head, wp_body_open, wp_footer.
 * WordPress shortcodes in stored code are expanded on output. Hooks run at moderate priority so the main
 * query and third-party shortcodes are available. Only registers hooks for locations with non-empty code.
 *
 * @package HeaderFooterCode
 * @since 1.0.0
 */

namespace HeaderFooterCode;

use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\AbstractModule;
class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID = 'header-footer-code';
	private const MODULE_VERSION = '1.2.1';
	private const MAX_CODE_LENGTH = 100000;

private ?array $settings_cache = null;

	/** @var bool True once body code has been output via wp_body_open (prevents fallback double-output). */
	private bool $body_output_done = false;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Header & Footer Code', 'wppowersuite-lite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Inject header, body, or footer snippets site-wide—analytics, pixels, chat, meta tags—without touching the theme.', 'wppowersuite-lite' );
	}

	public function getContext(): string|array {
		return [ 'frontend' ];
	}

	public function getDependencies(): array {
		return [];
	}


	protected function registerHooks(): void {
		if ( ! $this->isEnabled() || is_admin() ) {
		return;
		}

		if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ||
		 ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ||
		 ( defined( 'DOING_CRON' ) && DOING_CRON ) ||
		 is_feed() || is_robots() || is_trackback() ) {
		return;
		}

		add_action( 'wp_head', [ $this, 'outputHeaderCode' ], 100 );
		add_action( 'wp_body_open', [ $this, 'outputBodyCode' ], 100 );
		add_action( 'wp_footer', [ $this, 'outputFooterCode' ], 999 );

		// Fallback for themes that do not call wp_body_open: inject after <body> via output buffer.
		if ( $this->hasCode( $this->getSettings(), 'body' ) ) {
			add_action( 'template_redirect', [ $this, 'maybeStartBodyFallbackBuffer' ], 0 );
		}
	}


	private function hasCode( array $settings, string $location ): bool {
		$code = $settings[ $location . '_code' ] ?? '';
		return is_string( $code ) && trim( $code ) !== '';
	}

	public function outputHeaderCode(): void {
		$this->outputCode( 'header' );
	}

	public function outputBodyCode(): void {
		// Mark done so the fallback buffer does not also inject body code.
		$this->body_output_done = true;
		$this->outputCode( 'body' );
	}

	public function outputFooterCode(): void {
		$this->outputCode( 'footer' );
	}

	private function outputCode( string $location ): void {
		$code = $this->getCode( $location );
		if ( $code === '' ) {
			return;
		}
		$out = $this->buildOutput( $code, $location );
		if ( $out !== '' ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Intentional: user code must run.
			echo $out;
		}
	}

	/**
	 * Build the final, output-ready string for a location (sanitized, filtered, shortcodes expanded).
	 */
	private function buildOutput( string $code, string $location ): string {
		$code = $this->sanitizeOutput( $code );
		if ( trim( $code ) === '' ) {
			return '';
		}

		$code = apply_filters( 'wpps_header_footer_code_output', $code, $location );
		if ( ! is_string( $code ) || trim( $code ) === '' ) {
			return '';
		}

		$code = $this->expandShortcodes( $code );

		return "\n<!-- WP PowerSuite HFC (" . esc_attr( $location ) . ") -->\n" . $code . "\n<!-- /WP PowerSuite -->\n";
	}

	/**
	 * Start an output buffer to inject body code after <body> when the theme lacks wp_body_open.
	 */
	public function maybeStartBodyFallbackBuffer(): void {
		if ( $this->body_output_done ) {
			return;
		}
		ob_start( [ $this, 'injectBodyFallback' ] );
	}

	/**
	 * Output-buffer callback: inject body code right after the opening <body> tag.
	 *
	 * @param string $html Buffered page HTML.
	 * @return string
	 */
	public function injectBodyFallback( string $html ): string {
		// If wp_body_open already ran, or there is no <body>, leave the page untouched.
		if ( $this->body_output_done || $html === '' || ! preg_match( '/<body[^>]*>/i', $html ) ) {
			return $html;
		}
		$code = $this->getCode( 'body' );
		if ( $code === '' ) {
			return $html;
		}
		$out = $this->buildOutput( $code, 'body' );
		if ( $out === '' ) {
			return $html;
		}
		$this->body_output_done = true;
		$replaced = preg_replace( '/(<body[^>]*>)/i', '$1' . $out, $html, 1 );
		return is_string( $replaced ) ? $replaced : $html;
	}

	/**
	 * Run registered shortcodes, including when one shortcode’s output contains another.
	 */
	private function expandShortcodes( string $code ): string {
		for ( $i = 0; $i < 5; $i++ ) {
			$next = do_shortcode( $code );
			if ( $next === $code ) {
				return $next;
			}
			$code = $next;
		}
		return $code;
	}

	/**
	 * Sanitize code before output. Strips PHP, null bytes, control chars, ASP tags.
	 */
	private function sanitizeOutput( string $code ): string {
		$code = str_replace( "\0", '', $code );
		$code = preg_replace( '/<\?php.*?\?>/is', '', $code );
		$code = preg_replace( '/<\?.*?\?>/s', '', $code );
		$code = preg_replace( '/<\?=.*?\?>/s', '', $code );
		$code = preg_replace( '/<%.*?%>/s', '', $code );
		$code = preg_replace( '/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $code );
		return $code;
	}

	private function getCode( string $location ): string {
		$settings = $this->getSettings();
		$code     = $settings[ $location . '_code' ] ?? '';
		return is_string( $code ) ? trim( $code ) : '';
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$settings = $this->moduleSettings()->all( [] );
		$defaults = [
			'header_code' => '',
			'body_code'   => '',
			'footer_code' => '',
		];
		$this->settings_cache = array_merge( $defaults, is_array( $settings ) ? $settings : [] );

		return $this->settings_cache;
	}

	public static function getMaxCodeLength(): int {
		return self::MAX_CODE_LENGTH;
	}

	/**
	 * {@inheritdoc}
	 */
	public function getDataLocations(): array {
		return [
			'option_store_keys' => [ self::MODULE_ID ],
		];
	}

}
