<?php
/**
 * Blog Review & Publish — no-login route to review, edit, and publish a Blog
 * Agent draft from an emailed scoped magic link.
 *
 * This surface MUTATES data (it edits, publishes, and trashes a post), so it
 * layers a one-time CSRF token and per-IP rate limiting on top of the scoped,
 * expiring HMAC link. Possession of the emailed link IS the authorization —
 * there is no logged-in user, so WordPress capability checks are intentionally
 * bypassed, exactly as the leads page trusts its bearer token. The action is
 * scoped to a single Blog Agent post and to save/publish/trash only; discard is
 * a reversible trash, never a hard delete.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @since      1.28.0
 */

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

class SiteStaffr_Blog_Review {

	const SCOPE     = 'blog-review';
	const QUERY_KEY = 'sitestaffr_blog_review';
	const RL_MAX    = 20;  // Failed attempts per window per IP.
	const RL_WINDOW = 600; // 10 minutes.

	/**
	 * Whether a post id belongs to a Blog Agent post (only those are reviewable).
	 *
	 * @param int $post_id WordPress post ID.
	 * @return bool
	 */
	public static function is_blog_agent_post( $post_id ) {
		if ( ! class_exists( 'SiteStaffr_Blog_Manager' ) ) {
			return false;
		}
		return null !== SiteStaffr_Blog_Manager::get_job_by_wp_post_id( absint( $post_id ) );
	}

	/**
	 * Mint a one-time CSRF token bound to this post (1 hour TTL).
	 *
	 * @param int $post_id WordPress post ID.
	 * @return string
	 */
	public static function mint_csrf( $post_id ) {
		$token = wp_generate_password( 32, false );
		set_transient( 'ststf_blogrev_csrf_' . absint( $post_id ), $token, HOUR_IN_SECONDS );
		return $token;
	}

	/**
	 * Verify and consume the CSRF token (one-time use).
	 *
	 * @param int    $post_id WordPress post ID.
	 * @param string $token   Submitted token.
	 * @return bool
	 */
	public static function verify_csrf( $post_id, $token ) {
		$key    = 'ststf_blogrev_csrf_' . absint( $post_id );
		$stored = get_transient( $key );
		if ( ! $stored || ! is_string( $token ) || '' === $token ) {
			return false;
		}
		$ok = hash_equals( (string) $stored, $token );
		if ( $ok ) {
			delete_transient( $key );
		}
		return $ok;
	}

	/**
	 * MD5 of the requester IP (for keying the rate-limit transient).
	 *
	 * @return string
	 */
	private static function ip_hash() {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0';
		return md5( $ip );
	}

	/**
	 * Whether this IP is still under the failed-attempt cap.
	 *
	 * @param string|null $ip_hash Optional precomputed IP hash (tests).
	 * @return bool
	 */
	public static function rate_limit_ok( $ip_hash = null ) {
		$ip_hash = $ip_hash ? $ip_hash : self::ip_hash();
		$count   = (int) get_transient( 'ststf_blogrev_rl_' . $ip_hash );
		return $count < self::RL_MAX;
	}

	/**
	 * Record a failed verification for this IP.
	 *
	 * @param string|null $ip_hash Optional precomputed IP hash (tests).
	 * @return void
	 */
	public static function note_failure( $ip_hash = null ) {
		$ip_hash = $ip_hash ? $ip_hash : self::ip_hash();
		$key     = 'ststf_blogrev_rl_' . $ip_hash;
		$count   = (int) get_transient( $key );
		set_transient( $key, $count + 1, self::RL_WINDOW );
	}

	/**
	 * template_redirect entry point. Renders the edit page (GET) or handles a
	 * save/publish/discard write (POST), then exits.
	 *
	 * @return void
	 */
	public static function maybe_render_review() {
		// No logged-in user on this public surface, so a WP nonce is unavailable —
		// the scoped HMAC signature plus a one-time CSRF token are the anti-tampering
		// controls (signature verified below, CSRF in handle_write()).
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_GET[ self::QUERY_KEY ] ) || ! isset( $_GET['exp'] ) || ! isset( $_GET['sig'] ) ) {
			return;
		}

		if ( ! self::rate_limit_ok() ) {
			status_header( 429 );
			header( 'Retry-After: ' . self::RL_WINDOW );
			wp_die(
				esc_html__( 'Too many attempts. Please try again later.', 'sitestaffr' ),
				esc_html__( 'Slow down', 'sitestaffr' ),
				array( 'response' => 429 )
			);
		}

		$post_id = absint( wp_unslash( $_GET[ self::QUERY_KEY ] ) );
		$exp     = (int) wp_unslash( $_GET['exp'] );
		$sig     = sanitize_text_field( wp_unslash( $_GET['sig'] ) );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		if ( ! SiteStaffr_Magic_Link::verify_scoped( self::SCOPE, $post_id, $exp, $sig ) ) {
			self::note_failure();
			status_header( 403 );
			wp_die(
				esc_html__( 'This review link is invalid or has expired. Generate a new one from your Blog Agent dashboard.', 'sitestaffr' ),
				esc_html__( 'Invalid Link', 'sitestaffr' ),
				array( 'response' => 403 )
			);
		}

		$post = get_post( $post_id );
		if ( ! $post || 'post' !== $post->post_type || ! self::is_blog_agent_post( $post_id ) ) {
			status_header( 404 );
			wp_die(
				esc_html__( 'This post is no longer available.', 'sitestaffr' ),
				esc_html__( 'Not Found', 'sitestaffr' ),
				array( 'response' => 404 )
			);
		}

		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
		$notice = '';
		if ( 'POST' === $method ) {
			$notice = self::handle_write( $post_id );
			$post   = get_post( $post_id ); // Refresh after the write.
		}

		self::render_page( $post, $exp, $notice );
		exit;
	}

	/**
	 * Apply a save/publish/discard action.
	 *
	 * @param int $post_id WordPress post ID.
	 * @return string Status key: 'saved'|'published'|'discarded'|'error'|'csrf'|''.
	 */
	private static function handle_write( $post_id ) {
		// One-time CSRF token (verified below) stands in for the WP nonce that a
		// logged-out request cannot carry.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$action = isset( $_POST['review_action'] ) ? sanitize_key( wp_unslash( $_POST['review_action'] ) ) : '';
		$csrf   = isset( $_POST['csrf'] ) ? sanitize_text_field( wp_unslash( $_POST['csrf'] ) ) : '';

		if ( ! self::verify_csrf( $post_id, $csrf ) ) {
			self::note_failure();
			return 'csrf';
		}

		$status = get_post_status( $post_id );
		if ( ! in_array( $status, array( 'draft', 'pending', 'publish' ), true ) ) {
			return 'error';
		}

		if ( 'discard' === $action ) {
			$res = wp_trash_post( $post_id );
			SiteStaffr_Logger::legacy( 'SiteStaffr Blog Review: discard post ' . $post_id . ( $res ? ' ok' : ' FAILED' ) );
			return $res ? 'discarded' : 'error';
		}

		if ( in_array( $action, array( 'save', 'publish' ), true ) ) {
			$title      = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : '';
			$body       = isset( $_POST['body_html'] ) ? wp_kses_post( wp_unslash( $_POST['body_html'] ) ) : '';
			$excerpt    = isset( $_POST['meta_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['meta_description'] ) ) : '';
			$meta_title = isset( $_POST['meta_title'] ) ? sanitize_text_field( wp_unslash( $_POST['meta_title'] ) ) : '';
			// phpcs:enable WordPress.Security.NonceVerification.Missing

			$update = array( 'ID' => $post_id );
			if ( '' !== $title ) {
				$update['post_title'] = $title;
			}
			if ( '' !== $body ) {
				// The editor never shows the JSON-LD schema block (stripped for
				// display); re-append the existing one so saving here can't drop it.
				$update['post_content'] = $body . self::extract_schema_block( get_post_field( 'post_content', $post_id ) );
			}
			if ( 'publish' === $action ) {
				$update['post_status'] = 'publish';
			}

			$res = wp_update_post( $update, true );
			if ( is_wp_error( $res ) ) {
				SiteStaffr_Logger::legacy( 'SiteStaffr Blog Review: update failed for post ' . $post_id . ': ' . $res->get_error_message() );
				return 'error';
			}

			// Optional featured-image replacement. Non-fatal: a bad upload is logged
			// but the text edits still save/publish.
			if ( ! empty( $_FILES['featured_image']['name'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
				$img = self::handle_featured_image_upload( $post_id );
				if ( is_wp_error( $img ) ) {
					SiteStaffr_Logger::legacy( 'SiteStaffr Blog Review: featured image upload failed for post ' . $post_id . ': ' . $img->get_error_message() );
				}
			}

			// SEO meta title + description via the shared Blog Manager resolvers.
			if ( '' !== $meta_title ) {
				SiteStaffr_Blog_Manager::set_seo_meta_title( $post_id, $meta_title );
			}
			if ( '' !== $excerpt ) {
				SiteStaffr_Blog_Manager::set_seo_meta_description( $post_id, $excerpt );
			}

			SiteStaffr_Logger::legacy( 'SiteStaffr Blog Review: ' . $action . ' post ' . $post_id );
			return ( 'publish' === $action ) ? 'published' : 'saved';
		}

		return '';
	}

	/**
	 * Return the trailing JSON-LD schema block from post content, or ''.
	 *
	 * Blog Agent appends the schema to post_content (for sites without an SEO
	 * plugin). On the live post it is a hidden `<script type="application/ld+json">`
	 * that renders nothing, but wp_kses_post() strips the `<script>` wrapper and
	 * would leave the raw JSON visible in the review surface — so we pull it out
	 * for display and re-attach it on save.
	 *
	 * Matching is deliberately tolerant because the stored form varies:
	 *   1. A `<script type="application/ld+json">…</script>` block, with the type
	 *      attribute in any quoting/position and alongside other attributes.
	 *   2. A bare trailing JSON-LD array/object (`[{"@context":…}]` / `{"@context":…}`)
	 *      — what remains when the `<script>` wrapper was stripped by wp_kses_post()
	 *      at save time on a site whose writer lacked `unfiltered_html`.
	 * A strict exact-string regex missed both real variants and let raw schema leak.
	 *
	 * @param string $content Post content.
	 * @return string The schema block (with its leading whitespace) or ''.
	 */
	public static function extract_schema_block( $content ) {
		$content = (string) $content;

		// 1. Wrapped <script type="application/ld+json"> … </script> (tolerant of
		//    attribute order, quoting, and surrounding whitespace).
		if ( preg_match( '#\s*<script\b[^>]*\btype\s*=\s*["\']?application/ld\+json["\']?[^>]*>.*?</script\s*>#is', $content, $m ) ) {
			return $m[0];
		}

		// 2. Bare trailing JSON-LD (wrapper stripped). Anchored to the end of the
		//    content and confined to a tag-free run ([^<]) so body text containing
		//    braces can never be mistaken for the schema block.
		if ( preg_match( '#\s*[\[{][^<]*?"@context"[^<]*?[\]}]\s*$#is', $content, $m ) ) {
			return $m[0];
		}

		return '';
	}

	/**
	 * Validate and sideload an uploaded featured image, then set it as the post
	 * thumbnail. Image-only (extension + MIME), size-capped, and run through
	 * WordPress's media handler — the upload is authenticated by the scoped link
	 * plus the one-time CSRF token already verified in handle_write().
	 *
	 * @param int $post_id WordPress post ID.
	 * @return int|WP_Error Attachment ID on success, WP_Error on failure.
	 */
	private static function handle_featured_image_upload( $post_id ) {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- CSRF token already verified by the caller.
		$file = isset( $_FILES['featured_image'] ) ? $_FILES['featured_image'] : null;
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		if ( ! is_array( $file ) || empty( $file['name'] ) || ! empty( $file['error'] ) ) {
			return new WP_Error( 'no_file', 'No image uploaded.' );
		}

		// Size cap (8 MB).
		if ( (int) $file['size'] > 8 * MB_IN_BYTES ) {
			return new WP_Error( 'too_large', 'Image is too large (max 8 MB).' );
		}

		// Image-only by extension + resolved MIME.
		$check   = wp_check_filetype( sanitize_file_name( $file['name'] ) );
		$ext     = $check['ext'] ? strtolower( $check['ext'] ) : '';
		$allowed = array( 'jpg', 'jpeg', 'png', 'gif', 'webp' );
		if ( ! in_array( $ext, $allowed, true ) || 0 !== strpos( (string) $check['type'], 'image/' ) ) {
			return new WP_Error( 'not_image', 'Only JPG, PNG, GIF, or WebP images are allowed.' );
		}

		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$overrides = array(
			'test_form' => false,
			'mimes'     => array(
				'jpg|jpeg' => 'image/jpeg',
				'png'      => 'image/png',
				'gif'      => 'image/gif',
				'webp'     => 'image/webp',
			),
		);

		$attach_id = media_handle_upload( 'featured_image', $post_id, array(), $overrides );
		if ( is_wp_error( $attach_id ) ) {
			return $attach_id;
		}

		set_post_thumbnail( $post_id, $attach_id );
		return $attach_id;
	}

	/**
	 * Render the branded no-login edit page.
	 *
	 * @param WP_Post $post   Post being reviewed.
	 * @param int     $exp    Expiry from the current link (re-used to re-sign the form action).
	 * @param string  $notice Status key from a prior write, if any.
	 * @return void
	 */
	private static function render_page( $post, $exp, $notice ) {
		nocache_headers();
		header( 'X-Robots-Tag: noindex, nofollow', true );

		$settings      = get_option( 'sitestaffr_settings', array() );
		$business_name = ! empty( $settings['business_name'] ) ? stripslashes( $settings['business_name'] ) : '';
		$logo_url      = SITESTAFFR_PLUGIN_URL . 'assets/images/logo.png';

		$post_status = get_post_status( $post );
		$csrf        = self::mint_csrf( $post->ID );
		$permalink   = get_permalink( $post->ID );
		$thumb       = get_the_post_thumbnail_url( $post->ID, 'large' );
		$meta_title  = SiteStaffr_Blog_Manager::get_seo_meta_title( $post->ID );
		$meta_desc   = SiteStaffr_Blog_Manager::get_seo_meta_description( $post->ID );

		// Body for display/editing WITHOUT the JSON-LD schema block (which would
		// otherwise show as raw JSON once wp_kses_post strips its <script> wrapper).
		$schema_block = self::extract_schema_block( $post->post_content );
		$body_html    = '' !== $schema_block
			? str_replace( $schema_block, '', $post->post_content )
			: $post->post_content;

		// Re-sign a fresh form action so the page can also be bookmarked and so the
		// POST round-trips the same scope/expiry that was just verified.
		$form_action = SiteStaffr_Magic_Link::generate_scoped_url( self::SCOPE, $post->ID, self::QUERY_KEY, max( 60, $exp - time() ) );

		wp_enqueue_style(
			'sitestaffr-transcript-viewer',
			SITESTAFFR_PLUGIN_URL . 'assets/css/sitestaffr-transcript-viewer.css',
			array(),
			SITESTAFFR_VERSION
		);
		wp_enqueue_style(
			'sitestaffr-blog-review',
			SITESTAFFR_PLUGIN_URL . 'assets/css/sitestaffr-blog-review.css',
			array( 'sitestaffr-transcript-viewer' ),
			SITESTAFFR_VERSION
		);

		// Load WordPress's bundled editor (TinyMCE/Quicktags) so wp.editor.initialize()
		// can mount the Classic editor on the body field. Enqueued here, before the
		// template prints wp_footer(), where the editor's footer scripts are emitted.
		// The body field degrades to a plain textarea if these fail to load.
		if ( in_array( $post_status, array( 'draft', 'pending' ), true ) ) {
			wp_enqueue_editor();
		}

		include SITESTAFFR_PLUGIN_DIR . 'templates/public/blog-review.php';
	}
}
