<?php
/**
 * Shared helpers for the companion abilities.
 *
 * @package WPAgentCompanion
 */

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

/**
 * Accepts only explicit true representations from Ability input.
 *
 * WordPress 6.9/7.0 validates a DELETE ability's normalized query input during
 * permission checks, but passes the raw query strings to execute_callback.
 * Therefore a JSON boolean may arrive here as "true". Loose casts are unsafe
 * because PHP treats the string "false" as true.
 *
 * @param mixed $value Raw Ability input value.
 */
function wpagent_input_is_true( $value ): bool {
	if ( true === $value || 1 === $value ) {
		return true;
	}
	if ( ! is_string( $value ) ) {
		return false;
	}
	$value = strtolower( trim( $value ) );
	return 'true' === $value || '1' === $value;
}

/**
 * Returns only shortcodes that WordPress can execute in the current request.
 *
 * Stored bracket text is not automatically executable. In particular, an
 * escaped shortcode such as [[example]] is rendered literally and must not
 * make generic content maintenance fail closed. get_shortcode_regex() is the
 * core parser contract and accepts the currently registered names, so this
 * does not make claims about inactive providers merely based on raw text.
 *
 * @return array<int,string>
 */
function wpagent_executable_shortcodes( string $content ): array {
	global $shortcode_tags;
	if ( ! str_contains( $content, '[' ) || ! is_array( $shortcode_tags ) || array() === $shortcode_tags ) {
		return array();
	}

	$matches = array();
	if ( ! preg_match_all( '/' . get_shortcode_regex( array_keys( $shortcode_tags ) ) . '/s', $content, $matches, PREG_SET_ORDER ) ) {
		return array();
	}

	$tags = array();
	foreach ( $matches as $match ) {
		if ( '[' === ( $match[1] ?? '' ) && ']' === ( $match[6] ?? '' ) ) {
			continue;
		}
		$tags[] = (string) ( $match[2] ?? '' );
	}
	$tags = array_values( array_unique( array_filter( $tags, static fn( string $tag ): bool => '' !== $tag ) ) );
	sort( $tags, SORT_STRING );
	return $tags;
}

/**
 * JSON value types accepted by WordPress' REST schema validator.
 *
 * ACF and selected option values are intentionally polymorphic. Even those
 * schema nodes still need an explicit type declaration: omitting it makes
 * rest_validate_value_from_schema() emit warnings on every request.
 */
const WPAGENT_MIXED_JSON_TYPES = array( 'array', 'object', 'string', 'number', 'integer', 'boolean', 'null' );

/**
 * Permission callback: the caller must be able to edit posts.
 *
 * The bot user is expected to be an Editor (never an Admin). The CLI enforces
 * the not-Admin rule at preflight; here we only require the edit capability.
 *
 * @param mixed $input Unused ability input.
 * @return bool
 */
function wpagent_perm_edit_posts( $input = null ): bool {
	return current_user_can( 'edit_posts' );
}

/**
 * Permission callback for abilities that accept one concrete post target.
 *
 * Do not resolve the post before this check. WordPress' edit_post meta
 * capability safely rejects both a missing ID and a post the caller may not
 * edit, so the REST permission boundary cannot become an existence oracle.
 *
 * @param mixed $input Ability input.
 */
function wpagent_perm_edit_post_target( $input = null ): bool {
	if ( ! is_array( $input ) ) {
		return false;
	}
	$post_id = filter_var( $input['post_id'] ?? null, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) );
	return false !== $post_id && current_user_can( 'edit_post', (int) $post_id );
}

/**
 * Permission callback: the caller must be able to edit pages.
 *
 * upsert-pattern always creates/updates post_type 'page', so it needs the
 * page capability, not the post one; an Editor has both by default, but the
 * two are not interchangeable for a custom role with only one of them.
 *
 * @param mixed $input Unused ability input.
 * @return bool
 */
function wpagent_perm_edit_pages( $input = null ): bool {
	return current_user_can( 'edit_pages' );
}

/**
 * Permission callback: the caller must be able to edit themes.
 *
 * write-theme-file puts arbitrary content on disk inside the active theme.
 * That is filesystem-adjacent, privileged territory an Editor does not get by
 * default, unlike the post-editing capabilities the other abilities rely on.
 *
 * @param mixed $input Unused ability input.
 * @return bool
 */
function wpagent_perm_edit_themes( $input = null ): bool {
	return current_user_can( 'edit_themes' );
}

/**
 * Permission callback: the caller must be able to activate/deactivate plugins.
 *
 * disable-power deactivates a plugin site-wide. That is a site-administration
 * action, not a content-editing one, so it is gated on the matching admin
 * capability instead of piggy-backing on edit_posts.
 *
 * @param mixed $input Unused ability input.
 * @return bool
 */
function wpagent_perm_activate_plugins( $input = null ): bool {
	return current_user_can( 'activate_plugins' );
}

/**
 * Ermittelt die tatsächliche Bearbeiten-Capability eines Post-Types über die
 * von WordPress selbst registrierten Capability-Mappings (WP_Post_Type::$cap),
 * statt 'page' irgendwo hart gegen edit_pages zu verdrahten. Für 'post' liefert
 * das 'edit_posts', für 'page' 'edit_pages', für Custom-Post-Types deren eigene
 * (ggf. abweichende) Capability.
 *
 * @param string $post_type
 * @return string
 */
function wpagent_post_type_edit_cap( string $post_type ): string {
	$object = get_post_type_object( $post_type );
	if ( ! $object || ! isset( $object->cap->edit_posts ) ) {
		return 'edit_posts';
	}
	$cap = (string) $object->cap->edit_posts;
	return '' === $cap ? 'do_not_allow' : $cap;
}

/**
 * Ermittelt eine Post-Type-Capability aus WP_Post_Type::$cap.
 *
 * @param string $post_type
 * @param string $cap_name
 * @param string $fallback
 * @return string
 */
function wpagent_post_type_cap( string $post_type, string $cap_name, string $fallback ): string {
	$object = get_post_type_object( $post_type );
	if ( ! $object || ! isset( $object->cap->{$cap_name} ) ) {
		return $fallback;
	}
	$cap = (string) $object->cap->{$cap_name};
	return '' === $cap ? 'do_not_allow' : $cap;
}

/**
 * Ermittelt die Create-Capability eines Post-Types. Custom-Post-Types können
 * create_posts bewusst anders als edit_posts oder auf do_not_allow setzen.
 *
 * @param string $post_type
 * @return string
 */
function wpagent_post_type_create_cap( string $post_type ): string {
	return wpagent_post_type_cap( $post_type, 'create_posts', wpagent_post_type_edit_cap( $post_type ) );
}

/**
 * Ermittelt die Publish-Capability eines Post-Types.
 *
 * @param string $post_type
 * @return string
 */
function wpagent_post_type_publish_cap( string $post_type ): string {
	return wpagent_post_type_cap( $post_type, 'publish_posts', 'publish_posts' );
}

/**
 * Prüft zusätzliche Status-Capabilities, die wp_insert_post()/wp_update_post()
 * in einem eigenen REST-Endpunkt nicht zuverlässig für uns erzwingen.
 *
 * @param string $post_type
 * @param string $status
 * @return true|WP_Error
 */
function wpagent_check_post_status_cap( string $post_type, string $status ) {
	if ( in_array( $status, array( 'publish', 'future', 'private' ), true ) ) {
		$cap = wpagent_post_type_publish_cap( $post_type );
		if ( ! current_user_can( $cap ) ) {
			return new WP_Error(
				'wpagent_forbidden_status',
				__( 'Keine Berechtigung, diesen Status für diesen Post-Type zu setzen.', 'wp-agent-companion' ),
				array( 'status' => 403 )
			);
		}
	}

	return true;
}

/**
 * Prüft, ob WordPress einen Papierkorb-Zustand tatsächlich unterstützt.
 *
 * wp_trash_post() und wp_trash_comment() löschen endgültig, wenn
 * EMPTY_TRASH_DAYS auf 0 steht. Abilities mit force=false dürfen diese
 * Kern-Fallbacks deshalb nur aufrufen, wenn der Papierkorb aktiv ist.
 *
 * @return bool
 */
function wpagent_trash_is_available(): bool {
	return defined( 'EMPTY_TRASH_DAYS' ) && (int) EMPTY_TRASH_DAYS > 0;
}

/**
 * Einheitlicher Fehler für force=false Löschpfade bei deaktiviertem Papierkorb.
 *
 * @return WP_Error
 */
function wpagent_trash_disabled_error(): WP_Error {
	return new WP_Error(
		'wpagent_trash_disabled',
		__( 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS=0). Der Remote-Vorgang wird blockiert, damit WordPress nicht endgültig löscht. Führe eine beabsichtigte permanente Löschung nur über eine separat beaufsichtigte lokale Administration aus.', 'wp-agent-companion' ),
		array( 'status' => 400 )
	);
}

/**
 * Parst ein Datum für post_date/post_date_gmt. Zeitstempel mit expliziter
 * Zeitzone (z. B. ISO-Strings mit Z oder +02:00) bleiben derselbe Zeitpunkt;
 * Angaben ohne Zeitzone werden als Website-Zeitzone interpretiert.
 *
 * @param string $date_input
 * @return array{timestamp:int,post_date:string,post_date_gmt:string}|WP_Error
 */
function wpagent_parse_post_date( string $date_input ) {
	$date_input = trim( $date_input );
	if ( '' === $date_input ) {
		return new WP_Error( 'wpagent_bad_date', __( 'Datum konnte nicht geparst werden.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}

	try {
		$date = new DateTimeImmutable( $date_input, wp_timezone() );
	} catch ( Exception $e ) {
		return new WP_Error( 'wpagent_bad_date', __( 'Datum konnte nicht geparst werden.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}

	return array(
		'timestamp'     => $date->getTimestamp(),
		'post_date'     => $date->setTimezone( wp_timezone() )->format( 'Y-m-d H:i:s' ),
		'post_date_gmt' => $date->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ),
	);
}

/**
 * Prüft, ob ein Beitrag ein gespeichertes Veröffentlichungsdatum in der Zukunft
 * hat. Relevant beim expliziten publish-Status: alte Zukunftsdaten würden
 * WordPress sonst wieder zu future normalisieren.
 *
 * @param WP_Post $post
 * @return bool
 */
function wpagent_post_has_future_date( WP_Post $post ): bool {
	if ( '' === $post->post_date_gmt || '0000-00-00 00:00:00' === $post->post_date_gmt ) {
		return false;
	}

	try {
		$date = new DateTimeImmutable( $post->post_date_gmt, new DateTimeZone( 'UTC' ) );
	} catch ( Exception $e ) {
		return false;
	}

	return $date->getTimestamp() > time();
}

/**
 * Validiert einen angeforderten post_type: er muss registriert und öffentlich
 * (public) sein. Verhindert den Zugriff auf interne Types wie nav_menu_item oder
 * revision über die generischen Content-Abilities.
 *
 * @param string $post_type
 * @return true|WP_Error
 */
function wpagent_validate_post_type( string $post_type ) {
	if ( ! post_type_exists( $post_type ) ) {
		return new WP_Error(
			'wpagent_bad_post_type',
			/* translators: %s: submitted post type name. */
			sprintf( __( 'Unbekannter Post-Type "%s".', 'wp-agent-companion' ), $post_type ),
			array( 'status' => 400 )
		);
	}

	$object = get_post_type_object( $post_type );
	if ( ! $object || ! $object->public ) {
		return new WP_Error(
			'wpagent_bad_post_type',
			/* translators: %s: submitted post type name. */
			sprintf( __( 'Post-Type "%s" ist nicht öffentlich und über diese Ability nicht zugänglich.', 'wp-agent-companion' ), $post_type ),
			array( 'status' => 400 )
		);
	}

	return true;
}

/**
 * Schützt WordPress-eigene Infrastruktur-Seiten (statische Startseite,
 * Beitragsseite) vor dem endgültigen Löschen. Ihre IDs stehen in den Optionen
 * page_on_front/page_for_posts; ihr Verschwinden reißt die Seitennavigation der
 * gesamten Site ein, und WordPress selbst verhindert das nicht.
 *
 * @param int $post_id
 * @return true|WP_Error
 */
function wpagent_check_protected_post( int $post_id ) {
	$front = (int) get_option( 'page_on_front' );
	if ( $front > 0 && $front === $post_id ) {
		return new WP_Error(
			'wpagent_protected_post',
			__( 'Diese Seite ist als statische Startseite eingestellt (page_on_front) und darf nicht gelöscht werden. Erst die Einstellung unter Einstellungen > Lesen ändern.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}

	$blog = (int) get_option( 'page_for_posts' );
	if ( $blog > 0 && $blog === $post_id ) {
		return new WP_Error(
			'wpagent_protected_post',
			__( 'Diese Seite ist als Beitragsseite eingestellt (page_for_posts) und darf nicht gelöscht werden. Erst die Einstellung unter Einstellungen > Lesen ändern.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}

	return true;
}

/**
 * Parse block markup and report block count, freeform count and block types.
 *
 * A null blockName with non-empty innerHTML is classic (freeform) content: the
 * exact silent-fallback signal the product is built to catch.
 *
 * @param string $content Serialized block markup (post_content).
 * @return array{block_count:int,freeform_count:int,block_types:string[]}
 */
function wpagent_block_report( string $content ): array {
	$blocks   = parse_blocks( $content );
	$count    = 0;
	$freeform = 0;
	$types    = array();

	foreach ( $blocks as $block ) {
		$name  = $block['blockName'];
		$inner = isset( $block['innerHTML'] ) ? trim( $block['innerHTML'] ) : '';

		if ( null === $name ) {
			// Whitespace between blocks parses to empty null blocks; ignore those.
			if ( '' !== $inner ) {
				$count++;
				$freeform++;
				$types[] = 'core/freeform';
			}
			continue;
		}

		$count++;
		$types[] = $name;
		if ( 'core/freeform' === $name ) {
			$freeform++;
		}
	}

	return array(
		'block_count'   => $count,
		'freeform_count' => $freeform,
		'block_types'   => array_values( array_unique( $types ) ),
	);
}

/**
 * Fail closed when server-generated content leaves the audited Core allowlist.
 *
 * This is deliberately independent from the Markdown converter. A future
 * converter regression must be caught after serialization and before a write.
 * Nested blocks are inspected as well, because an allowed container must not
 * hide an executable or provider-specific descendant.
 *
 * @return true|WP_Error
 */
function wpagent_validate_generated_core_blocks( string $content ) {
	$allowed = array(
		'core/button',
		'core/buttons',
		'core/code',
		'core/cover',
		'core/details',
		'core/gallery',
		'core/group',
		'core/heading',
		'core/image',
		'core/list',
		'core/list-item',
		'core/media-text',
		'core/paragraph',
		'core/quote',
		'core/separator',
		'core/table',
	);
	$stack   = parse_blocks( $content );

	while ( array() !== $stack ) {
		$block = array_pop( $stack );
		$name  = $block['blockName'] ?? null;
		$inner = isset( $block['innerHTML'] ) ? trim( (string) $block['innerHTML'] ) : '';

		if ( null === $name ) {
			if ( '' !== $inner ) {
				return new WP_Error(
					'wpagent_generated_block_not_allowed',
					__( 'Interner Sicherheitsfehler: Der erzeugte Inhalt enthält freies HTML oder einen nicht erlaubten Block.', 'wp-agent-companion' ),
					array( 'status' => 500, 'block' => 'core/freeform' )
				);
			}
		} elseif ( ! in_array( $name, $allowed, true ) ) {
			return new WP_Error(
				'wpagent_generated_block_not_allowed',
				__( 'Interner Sicherheitsfehler: Der erzeugte Inhalt enthält freies HTML oder einen nicht erlaubten Block.', 'wp-agent-companion' ),
				array( 'status' => 500, 'block' => sanitize_key( $name ) )
			);
		}

		foreach ( (array) ( $block['innerBlocks'] ?? array() ) as $child ) {
			$stack[] = $child;
		}
	}

	return true;
}
