<?php
/**
 * ppok/preview-url — mint a short-lived (5-minute) HMAC-signed URL that
 * bypasses the draft/pending/private redirect for visual review without
 * admin login.
 *
 * Read-effect ability flagged write=true so each mint produces one audit
 * row (a bypass credential is a side-effect worth logging) and so it
 * requires explicit admin opt-in via abilities_enabled. L3/L4 do not
 * apply — no block tree, no DB mutation — so the ability never invokes
 * them; L5 attaches automatically via Ability_Registrar::wrap_with_logger.
 *
 * The front-end consumer (query_vars filter + pre_get_posts handler)
 * lives on this same class so the token mint and token verify never
 * drift out of sync. Plugin::define_abilities_hooks() wires the hooks.
 *
 * @package Ppok\AIToolkit
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Abilities;

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

final class Preview_Url {

	public const SLUG            = 'ppok/preview-url';
	public const TTL_SECONDS     = 300;
	public const QUERY_VAR_TOKEN = 'ppok_preview_token';
	public const QUERY_VAR_EXP   = 'ppok_preview_expires';

	public static function descriptor(): array {
		return array(
			'label'               => __( 'Generate preview URL', 'ppok-ai-toolkit' ),
			'description'         => __( 'Mint a short-lived (5-minute) signed URL that bypasses the draft/pending/private redirect for visual review without admin login.', 'ppok-ai-toolkit' ),
			'category'            => 'ppok',
			'input_schema'        => self::input_schema(),
			'output_schema'       => self::output_schema(),
			'permission_callback' => array( __CLASS__, 'permission_callback' ),
			'execute_callback'    => array( __CLASS__, 'execute_callback' ),
			'meta'                => array(
				'show_in_rest' => true,
				'mcp'          => array( 'public' => false ),
				'ppok'         => array( 'write' => true ),
			),
		);
	}

	public static function input_schema(): array {
		return array(
			'type'                 => 'object',
			'required'             => array( 'post_id', 'reason' ),
			'additionalProperties' => false,
			'properties'           => array(
				'post_id' => array(
					'type'        => 'integer',
					'minimum'     => 1,
					'description' => __( 'Post ID to generate a preview URL for.', 'ppok-ai-toolkit' ),
				),
				'reason'  => array(
					'type'      => 'string',
					'minLength' => 8,
					'maxLength' => 500,
				),
			),
		);
	}

	public static function output_schema(): array {
		return array(
			'type'       => 'object',
			'required'   => array( 'preview_url', 'expires_at', 'expires_in_seconds', 'post_id', 'post_status' ),
			'properties' => array(
				'preview_url'        => array( 'type' => 'string', 'format' => 'uri' ),
				'expires_at'         => array( 'type' => 'string', 'format' => 'date-time' ),
				'expires_in_seconds' => array( 'type' => 'integer' ),
				'post_id'            => array( 'type' => 'integer' ),
				'post_status'        => array( 'type' => 'string' ),
			),
		);
	}

	public static function example(): array {
		return array(
			'post_id' => 1,
			'reason'  => 'Sharing the redesigned homepage hero with the client before publish.',
		);
	}

	public static function permission_callback( array $args = array() ): bool {
		$post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
		if ( $post_id < 1 ) {
			return false;
		}
		return current_user_can( 'edit_post', $post_id );
	}

	public static function execute_callback( array $args = array() ) {
		$post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
		$post    = $post_id > 0 ? get_post( $post_id ) : null;

		if ( ! $post instanceof \WP_Post ) {
			return new \WP_Error(
				'ppok_post_not_found',
				__( 'Post not found.', 'ppok-ai-toolkit' ),
				array( 'status' => 404 )
			);
		}

		$now        = time();
		$expires_at = $now + self::TTL_SECONDS;
		$token      = self::mint_token( (int) $post->ID, $expires_at );

		$base_url = home_url( '/' );
		$query    = array(
			'p'                    => (int) $post->ID,
			'post_type'            => (string) $post->post_type,
			self::QUERY_VAR_TOKEN  => $token,
			self::QUERY_VAR_EXP    => $expires_at,
		);

		// 'post' uses ?p=; CPTs need ?post_type= as well. ?p= is honored
		// regardless of permalink structure, which matters for drafts.
		$preview_url = add_query_arg( $query, $base_url );

		return array(
			'preview_url'        => $preview_url,
			'expires_at'         => gmdate( 'c', $expires_at ),
			'expires_in_seconds' => self::TTL_SECONDS,
			'post_id'            => (int) $post->ID,
			'post_status'        => (string) $post->post_status,
		);
	}

	public static function mint_token( int $post_id, int $expires_at ): string {
		return hash_hmac( 'sha256', $post_id . '|' . $expires_at, wp_salt( 'auth' ) );
	}

	public static function verify_token( int $post_id, int $expires_at, string $token ): bool {
		if ( $post_id < 1 || $expires_at <= 0 || $token === '' ) {
			return false;
		}
		if ( $expires_at < time() ) {
			return false;
		}
		$expected = self::mint_token( $post_id, $expires_at );
		return hash_equals( $expected, $token );
	}

	/**
	 * Register the query vars our preview URL carries so WP_Query picks
	 * them up instead of dropping them on the floor.
	 *
	 * @param array<int,string> $vars
	 * @return array<int,string>
	 */
	public static function register_query_vars( array $vars ): array {
		$vars[] = self::QUERY_VAR_TOKEN;
		$vars[] = self::QUERY_VAR_EXP;
		return $vars;
	}

	/**
	 * If the request carries a valid preview token, expand the main
	 * query's post_status filter so the draft/pending/private/future
	 * post survives the public-status check. Invalid or expired token
	 * is a no-op — WP serves its normal 404.
	 */
	public static function maybe_allow_preview_query( \WP_Query $query ): void {
		if ( is_admin() || ! $query->is_main_query() ) {
			return;
		}

		$token      = (string) $query->get( self::QUERY_VAR_TOKEN );
		$expires_at = (int) $query->get( self::QUERY_VAR_EXP );
		if ( $token === '' || $expires_at <= 0 ) {
			return;
		}

		// Resolve the post_id the request points at. ?p= is the primary
		// path for drafts; we don't try to support slug-based URLs here
		// because draft slugs are unreliable and the mint side never
		// produces them.
		$post_id = (int) $query->get( 'p' );
		if ( $post_id < 1 ) {
			$post_id = (int) $query->get( 'page_id' );
		}
		if ( $post_id < 1 ) {
			return;
		}

		if ( ! self::verify_token( $post_id, $expires_at, $token ) ) {
			return;
		}

		$query->set(
			'post_status',
			array( 'publish', 'draft', 'pending', 'private', 'future' )
		);
	}
}
