<?php
/**
 * Niwa AI Blog Asset Migrator.
 *
 * One-shot migration utility that scans WordPress posts for the legacy
 * Devona Bloger CSS / progress-bar JS / JSON-LD payload leak — content that
 * ended up in `post_content` because `wp_filter_post_kses()` stripped the
 * surrounding `<style>` / `<script>` tags but kept their bodies as plain text.
 *
 * The migrator strips those leaked blocks from `post_content` and
 * regenerates fresh `_niwa_blog_css`, `_niwa_blog_progress_js`, and
 * `_niwa_blog_schemas` post meta so the Blog Asset Renderer can re-emit them
 * via `wp_head` / `wp_footer`.
 *
 * Run via admin-post action `niwa_ai_blog_migrate_assets` (nonce-protected).
 *
 * @package Niwa_AI
 * @since   0.13.126
 */

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

class Niwa_AI_Blog_Migrator {

	use Niwa_AI_Tools_Blog_Pipeline;

	const ACTION_RUN  = 'niwa_ai_blog_migrate_assets';
	const ACTION_SCAN = 'niwa_ai_blog_migrate_scan';
	const NONCE_NAME  = 'niwa_ai_blog_migrate_nonce';
	const META_BACKUP = '_niwa_blog_migration_backup';

	/**
	 * Register WordPress hooks for the admin-post endpoints.
	 */
	public static function register_hooks() {
		add_action( 'admin_post_' . self::ACTION_RUN, array( __CLASS__, 'handle_admin_post_run' ) );
		add_action( 'admin_post_' . self::ACTION_SCAN, array( __CLASS__, 'handle_admin_post_scan' ) );
	}

	/* ==================================================================
	 *  Admin-post handlers
	 * ================================================================== */

	/**
	 * admin-post.php handler — dry-run scan that reports affected posts
	 * without modifying anything.
	 */
	public static function handle_admin_post_scan() {
		self::guard_admin_request( self::ACTION_SCAN );

		$migrator = new self();
		$report   = $migrator->scan();

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'                          => 'niwa-ai',
					'niwa_ai_blog_migrate_scanned'  => (int) $report['affected'],
					'niwa_ai_blog_migrate_total'    => (int) $report['total'],
				),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * admin-post.php handler — runs the migration over all affected posts.
	 */
	public static function handle_admin_post_run() {
		self::guard_admin_request( self::ACTION_RUN );

		$migrator = new self();
		$report   = $migrator->migrate_all();

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'                           => 'niwa-ai',
					'niwa_ai_blog_migrate_done'      => (int) $report['migrated'],
					'niwa_ai_blog_migrate_skipped'   => (int) $report['skipped'],
					'niwa_ai_blog_migrate_failed'    => (int) $report['failed'],
				),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * Guard for admin-post.php requests: capability + nonce.
	 *
	 * @param string $action Action name (matched against nonce).
	 */
	private static function guard_admin_request( $action ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Niste autorizovani za ovu akciju.', 'niwa-ai' ), '', array( 'response' => 403 ) );
		}
		$nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, $action ) ) {
			wp_die( esc_html__( 'Bezbednosna provera neuspešna (nonce).', 'niwa-ai' ), '', array( 'response' => 403 ) );
		}
	}

	/* ==================================================================
	 *  Public API
	 * ================================================================== */

	/**
	 * Scan all posts and count how many have leaked asset payloads.
	 *
	 * @return array{total:int,affected:int,affected_ids:array<int>}
	 */
	public function scan() {
		$ids      = $this->collect_post_ids();
		$affected = array();

		foreach ( $ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof WP_Post ) {
				continue;
			}
			if ( $this->content_has_leak( $post->post_content ) ) {
				$affected[] = (int) $post_id;
			}
		}

		return array(
			'total'        => count( $ids ),
			'affected'     => count( $affected ),
			'affected_ids' => $affected,
		);
	}

	/**
	 * Migrate every affected post.
	 *
	 * @return array{migrated:int,skipped:int,failed:int,details:array}
	 */
	public function migrate_all() {
		$ids      = $this->collect_post_ids();
		$migrated = 0;
		$skipped  = 0;
		$failed   = 0;
		$details  = array();

		foreach ( $ids as $post_id ) {
			$result = $this->migrate_post( (int) $post_id );
			$details[ (int) $post_id ] = $result;

			if ( ! empty( $result['ok'] ) ) {
				$migrated++;
			} elseif ( ! empty( $result['skipped'] ) ) {
				$skipped++;
			} else {
				$failed++;
			}
		}

		return array(
			'migrated' => $migrated,
			'skipped'  => $skipped,
			'failed'   => $failed,
			'details'  => $details,
		);
	}

	/**
	 * Migrate a single post.
	 *
	 * @param int $post_id WordPress post ID.
	 * @return array Result with one of { ok:true } | { skipped: reason } | { error: msg }
	 */
	public function migrate_post( $post_id ) {
		$post = get_post( $post_id );
		if ( ! $post instanceof WP_Post ) {
			return array( 'error' => 'post_not_found' );
		}

		$original = (string) $post->post_content;
		if ( ! $this->content_has_leak( $original ) ) {
			return array( 'skipped' => 'no_leak' );
		}

		$snapshot = $this->build_migration_snapshot( $post );
		if ( ! $this->blog_pipeline_store_meta_verified( $post_id, self::META_BACKUP, $snapshot ) ) {
			return array( 'error' => 'migration_backup_failed' );
		}

		$cleaned = $this->strip_leak( $original );
		if ( ! preg_match( '/class=["\'][^"\']*\bniwa-blog-article\b/i', $cleaned ) ) {
			$cleaned = '<div class="niwa-blog-article">' . $cleaned . '</div>';
		}

		/* Update post content. */
		$update = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $cleaned,
			),
			true
		);
		if ( is_wp_error( $update ) ) {
			return array( 'error' => $update->get_error_message() );
		}

		/* Regenerate post meta. */
		$accent_color = '#3b82f6';
		$writes = array(
			Niwa_AI_Blog_Asset_Renderer::META_CSS     => $this->blog_pipeline_generate_css( $accent_color ),
			Niwa_AI_Blog_Asset_Renderer::META_JS      => $this->blog_pipeline_generate_progress_bar_js_body(),
			Niwa_AI_Blog_Asset_Renderer::META_SCHEMAS => $this->build_basic_schemas( get_post( $post_id ) ),
		);
		foreach ( $writes as $key => $value ) {
			if ( $this->blog_pipeline_store_meta_verified( $post_id, $key, $value ) ) {
				continue;
			}
			$restored = $this->restore_migration_snapshot( $post_id, $snapshot );
			return array( 'error' => 'migration_meta_write_failed:' . $key, 'restored' => ! empty( $restored['success'] ) );
		}

		return array( 'ok' => true, 'rollback_available' => true );
	}

	/** Restore the last successful pre-migration snapshot for one post. */
	public function rollback_post( $post_id ) {
		$snapshot = get_post_meta( intval( $post_id ), self::META_BACKUP, true );
		if ( ! is_array( $snapshot ) ) {
			return array( 'success' => false, 'error' => 'migration_backup_not_found' );
		}
		$result = $this->restore_migration_snapshot( intval( $post_id ), $snapshot );
		if ( ! empty( $result['success'] ) ) {
			delete_post_meta( intval( $post_id ), self::META_BACKUP );
		}
		return $result;
	}

	private function build_migration_snapshot( $post ) {
		$meta = array();
		foreach ( array( Niwa_AI_Blog_Asset_Renderer::META_CSS, Niwa_AI_Blog_Asset_Renderer::META_JS, Niwa_AI_Blog_Asset_Renderer::META_SCHEMAS ) as $key ) {
			$exists       = metadata_exists( 'post', $post->ID, $key );
			$meta[ $key ] = array( 'exists' => $exists, 'values' => $exists ? get_post_meta( $post->ID, $key, false ) : array() );
		}
		return array(
			'content'      => (string) $post->post_content,
			'modified'     => (string) $post->post_modified,
			'modified_gmt' => (string) $post->post_modified_gmt,
			'meta'         => $meta,
		);
	}

	private function restore_migration_snapshot( $post_id, $snapshot ) {
		$updated = wp_update_post( array( 'ID' => intval( $post_id ), 'post_content' => (string) $snapshot['content'] ), true );
		if ( is_wp_error( $updated ) ) {
			return array( 'success' => false, 'error' => $updated->get_error_message() );
		}
		foreach ( (array) $snapshot['meta'] as $key => $state ) {
			delete_post_meta( $post_id, $key );
			if ( empty( $state['exists'] ) ) {
				continue;
			}
			foreach ( (array) $state['values'] as $value ) {
				add_post_meta( $post_id, $key, $value );
			}
		}
		global $wpdb;
		if ( isset( $wpdb ) && isset( $wpdb->posts ) && method_exists( $wpdb, 'update' ) ) {
			$wpdb->update(
				$wpdb->posts,
				array( 'post_modified' => (string) $snapshot['modified'], 'post_modified_gmt' => (string) $snapshot['modified_gmt'] ),
				array( 'ID' => intval( $post_id ) ),
				array( '%s', '%s' ),
				array( '%d' )
			);
			if ( function_exists( 'clean_post_cache' ) ) {
				clean_post_cache( intval( $post_id ) );
			}
		}
		return array( 'success' => true );
	}

	/* ==================================================================
	 *  Detection + cleanup
	 * ================================================================== */

	/**
	 * Does the post content show signs of the Devona Bloger leak?
	 *
	 * @param string $content Post content.
	 * @return bool
	 */
	private function content_has_leak( $content ) {
		if ( '' === $content ) {
			return false;
		}
		/* CSS section marker — only appears when the <style> tag got stripped
		 * and the comment leaked out as text. Plain `Devona Bloger` is too
		 * broad (appears in legitimate articles about the pipeline). */
		if ( false !== strpos( $content, '/* Devona Bloger' ) ) {
			return true;
		}
		/* HTML comment marker from older pipeline iterations + matching empty
		 * <script> stubs that the editor left behind. */
		if ( false !== stripos( $content, '<!-- Devona Bloger' ) ) {
			return true;
		}
		/* Empty JSON-LD script tags (typical leftover after WP stripped the
		 * payload but kept the wrapper). */
		if ( false !== strpos( $content, '<script type="application/ld+json"></script>' ) ) {
			return true;
		}
		/* Raw JSON-LD payloads (any flavor of escaped slashes). */
		if ( false !== strpos( $content, '"@context":"https://schema.org"' ) ) {
			return true;
		}
		if ( false !== strpos( $content, '"@context":"https:\/\/schema.org"' ) ) {
			return true;
		}
		/* Progress-bar IIFE — only present when the <script> got stripped. The
		 * legitimate `<div class="db-progress-bar" id="db-progress">` element
		 * does NOT contain `getElementById`, so this disambiguates the leak. */
		if ( false !== strpos( $content, "getElementById('db-progress'" )
			|| false !== strpos( $content, 'getElementById("db-progress"' )
			|| false !== strpos( $content, "getElementById(\xE2\x80\x98db-progress" ) /* smart-quote left  */
			|| false !== strpos( $content, "getElementById(\xE2\x80\x99db-progress" ) /* smart-quote right */
		) {
			return true;
		}
		/* Redundant Niwa-emitted sections that we no longer want in
		 * post_content (the active theme provides its own share buttons;
		 * money-posts and related cards added visual noise). */
		if ( false !== strpos( $content, 'class="db-share"' ) ) {
			return true;
		}
		if ( false !== strpos( $content, 'class="db-money-links"' ) ) {
			return true;
		}
		if ( false !== strpos( $content, 'class="db-related"' ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Strip leaked CSS / JS / JSON-LD blocks from post content.
	 *
	 * @param string $content Post content.
	 * @return string Cleaned content.
	 */
	private function strip_leak( $content ) {
		$cleaned = (string) $content;

		/* 1) Strip JSON-LD payloads (top-level JSON objects with @context). */
		$cleaned = $this->strip_json_ld_blocks( $cleaned );

		/* 2) Strip progress-bar JS — IIFE referencing db-progress.
		 * Anchored on the unique getElementById('db-progress') marker. WP may
		 * have converted straight quotes to typographic ones, so we accept
		 * both. */
		$cleaned = preg_replace(
			'#\(function\(\)\s*\{[\s\S]*?getElementById\([\'\x{2018}\x{2019}]db-progress[\'\x{2018}\x{2019}]\)[\s\S]*?\}\)\(\);#u',
			'',
			$cleaned
		);

		/* 3) Strip Devona Bloger CSS blocks. Each section starts with the
		 * comment `/* Devona Bloger ...` and ends at the final `}` of its
		 * last `@media` block. Multiple sections are stripped greedily. */
		$cleaned = preg_replace(
			'#/\*\s*Devona Bloger[\s\S]*?@media[^{]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}#u',
			'',
			$cleaned
		);

		/* 4) Backup pass for stray CSS rules that escaped the section sweep
		 * (e.g. when the comment marker got mangled by the editor). Strip
		 * top-level `.db-*` selectors with their declaration blocks. */
		$cleaned = preg_replace(
			'#(?:^|\n)\s*\.db-[a-zA-Z0-9_\-]+(?:\s*[,>+~]\s*\.?db?-?[a-zA-Z0-9_\-]*)?\s*\{[^{}]*\}#u',
			'',
			$cleaned
		);

		/* 5) Strip leftover HTML comments from older pipeline iterations
		 * (`<!-- Devona Bloger ... -->`) and empty JSON-LD script stubs. */
		$cleaned = preg_replace( '#<!--\s*Devona Bloger[\s\S]*?-->#u', '', $cleaned );
		$cleaned = str_replace( '<script type="application/ld+json"></script>', '', $cleaned );

		/* 6) Strip redundant Niwa sections that we no longer emit:
		 *    - `<div class="db-share">...</div>` (share buttons — theme already
		 *      provides these; Niwa was emitting them twice, above and below
		 *      the content)
		 *    - `<section class="db-money-links">...</section>` (key-pages cards)
		 *    - `<section class="db-related">...</section>` (related-posts cards)
		 */
		$cleaned = $this->strip_balanced_tag_block( $cleaned, 'div', 'class="db-share"' );
		$cleaned = $this->strip_balanced_tag_block( $cleaned, 'section', 'class="db-money-links"' );
		$cleaned = $this->strip_balanced_tag_block( $cleaned, 'section', 'class="db-related"' );

		/* 7) Collapse leftover whitespace. */
		$cleaned = preg_replace( "/[\r\n]{3,}/", "\n\n", $cleaned );
		$cleaned = trim( $cleaned );

		return $cleaned;
	}

	/**
	 * Strip every `<TAG ...CLASS_ATTR...> ... </TAG>` block, respecting
	 * nested tags of the same name.
	 *
	 * @param string $content    Post content.
	 * @param string $tag        HTML tag name (lowercase, e.g. `div`).
	 * @param string $class_attr Substring that must appear in the opening tag
	 *                           (e.g. `class="db-share"`).
	 * @return string
	 */
	private function strip_balanced_tag_block( $content, $tag, $class_attr ) {
		$open_prefix     = '<' . $tag;
		$open_prefix_len = strlen( $open_prefix );
		$close_tag       = '</' . $tag . '>';
		$close_tag_len   = strlen( $close_tag );

		$result = '';
		$cursor = 0;
		$len    = strlen( $content );

		while ( $cursor < $len ) {
			$open_start = stripos( $content, $open_prefix, $cursor );
			if ( false === $open_start ) {
				$result .= substr( $content, $cursor );
				break;
			}

			/* Ensure this is a real opening tag (not e.g. `<sectionfoo`). */
			$after_pos = $open_start + $open_prefix_len;
			$after_ch  = $after_pos < $len ? $content[ $after_pos ] : '';
			$is_real   = ( ' ' === $after_ch || "\t" === $after_ch || "\n" === $after_ch || "\r" === $after_ch || '>' === $after_ch || '/' === $after_ch );
			if ( ! $is_real ) {
				$result .= substr( $content, $cursor, $open_start - $cursor + $open_prefix_len );
				$cursor  = $open_start + $open_prefix_len;
				continue;
			}

			$open_end = strpos( $content, '>', $open_start );
			if ( false === $open_end ) {
				$result .= substr( $content, $cursor );
				break;
			}
			$open_tag = substr( $content, $open_start, $open_end - $open_start + 1 );

			/* Not a target tag — copy through and keep scanning. */
			if ( false === stripos( $open_tag, $class_attr ) ) {
				$result .= substr( $content, $cursor, $open_end - $cursor + 1 );
				$cursor  = $open_end + 1;
				continue;
			}

			/* Target tag — copy the gap up to its start, then look for the
			 * matching `</tag>` accounting for nested same-name tags. */
			$result .= substr( $content, $cursor, $open_start - $cursor );

			$depth = 1;
			$probe = $open_end + 1;
			$found = -1;
			while ( $probe < $len ) {
				$next_close = stripos( $content, $close_tag, $probe );
				if ( false === $next_close ) {
					break;
				}
				$next_open  = stripos( $content, $open_prefix, $probe );

				/* Confirm next_open is a real same-tag opener. */
				$next_open_is_real = false;
				if ( false !== $next_open && $next_open < $next_close ) {
					$after2_pos = $next_open + $open_prefix_len;
					$after2_ch  = $after2_pos < $len ? $content[ $after2_pos ] : '';
					$next_open_is_real = ( ' ' === $after2_ch || "\t" === $after2_ch || "\n" === $after2_ch || "\r" === $after2_ch || '>' === $after2_ch || '/' === $after2_ch );
				}

				if ( $next_open_is_real ) {
					$depth++;
					$probe = $next_open + $open_prefix_len;
					continue;
				}

				$depth--;
				if ( 0 === $depth ) {
					$found = $next_close;
					break;
				}
				$probe = $next_close + $close_tag_len;
			}

			if ( -1 === $found ) {
				/* Unbalanced — bail out and keep the rest as-is. */
				$result .= substr( $content, $open_start );
				return $result;
			}

			$cursor = $found + $close_tag_len;
		}

		return $result;
	}

	/**
	 * Strip top-level JSON-LD objects whose first key is `@context`.
	 *
	 * Walks the string and matches balanced braces — regex alone cannot
	 * reliably handle nested objects.
	 *
	 * @param string $content Post content.
	 * @return string
	 */
	private function strip_json_ld_blocks( $content ) {
		$len    = strlen( $content );
		$result = '';
		$i      = 0;
		$marker = '{"@context":"http';

		while ( $i < $len ) {
			$pos = strpos( $content, $marker, $i );
			if ( false === $pos ) {
				$result .= substr( $content, $i );
				break;
			}

			/* Copy non-matching tail up to the marker. */
			$result .= substr( $content, $i, $pos - $i );

			/* Walk to matching closing brace from $pos. */
			$end = $this->find_matching_brace( $content, $pos );
			if ( -1 === $end ) {
				/* Unbalanced — keep the rest as-is, bail out. */
				$result .= substr( $content, $pos );
				break;
			}

			$i = $end + 1;
		}

		return $result;
	}

	/**
	 * Find the matching `}` for the `{` at $start, respecting strings.
	 *
	 * @param string $s     The input string.
	 * @param int    $start Position of the opening `{`.
	 * @return int Position of the matching `}`, or -1.
	 */
	private function find_matching_brace( $s, $start ) {
		$len     = strlen( $s );
		$depth   = 0;
		$in_str  = false;
		$escape  = false;

		for ( $i = $start; $i < $len; $i++ ) {
			$ch = $s[ $i ];

			if ( $in_str ) {
				if ( $escape ) {
					$escape = false;
				} elseif ( '\\' === $ch ) {
					$escape = true;
				} elseif ( '"' === $ch ) {
					$in_str = false;
				}
				continue;
			}

			if ( '"' === $ch ) {
				$in_str = true;
			} elseif ( '{' === $ch ) {
				$depth++;
			} elseif ( '}' === $ch ) {
				$depth--;
				if ( 0 === $depth ) {
					return $i;
				}
			}
		}
		return -1;
	}

	/* ==================================================================
	 *  Helpers
	 * ================================================================== */

	/**
	 * Collect IDs of all `post` posts (any status) eligible for migration.
	 *
	 * @return array<int>
	 */
	private function collect_post_ids() {
		global $wpdb;
		$ids = $wpdb->get_col(
			"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status IN ('publish','draft','pending','private','future') ORDER BY ID ASC"
		);
		return array_map( 'intval', (array) $ids );
	}

	/**
	 * Build basic JSON-LD schemas for a migrated post — used when we no
	 * longer have the original markdown.
	 *
	 * @param WP_Post $post Post object.
	 * @return array Array of JSON strings.
	 */
	private function build_basic_schemas( WP_Post $post ) {
		$site_url  = home_url( '/' );
		$site_name = get_bloginfo( 'name' );
		$post_url  = get_permalink( $post );
		$published = $post->post_date_gmt ? gmdate( 'Y-m-d\TH:i:s+00:00', strtotime( $post->post_date_gmt ) ) : gmdate( 'Y-m-d\TH:i:s+00:00' );
		$modified  = $post->post_modified_gmt ? gmdate( 'Y-m-d\TH:i:s+00:00', strtotime( $post->post_modified_gmt ) ) : $published;
		$excerpt   = $post->post_excerpt ? $post->post_excerpt : wp_trim_words( wp_strip_all_tags( $post->post_content ), 30, '...' );
		$author    = get_userdata( intval( $post->post_author ) );
		if ( ! $author || defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || function_exists( 'rank_math' ) ) {
			return array();
		}

		$schemas = array();

		$blog_schema = array(
			'@context'         => 'https://schema.org',
			'@type'            => 'BlogPosting',
			'headline'         => $post->post_title,
			'description'     => $excerpt,
			'url'              => $post_url,
			'datePublished'    => $published,
			'dateModified'     => $modified,
			'wordCount'        => count( preg_split( '/[^\p{L}\p{N}]+/u', wp_strip_all_tags( $post->post_content ), -1, PREG_SPLIT_NO_EMPTY ) ),
			'inLanguage'       => get_bloginfo( 'language' ),
			'author'           => array(
				'@type' => 'Person',
				'name'  => $author->display_name,
				'url'   => get_author_posts_url( $author->ID ),
			),
			'publisher'        => array(
				'@type' => 'Organization',
				'name'  => $site_name,
				'url'   => $site_url,
			),
			'mainEntityOfPage' => array(
				'@type' => 'WebPage',
				'@id'   => $post_url,
			),
		);
		$schemas[] = wp_json_encode( $blog_schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		$breadcrumb_items = array(
			array(
				'@type'    => 'ListItem',
				'position' => 1,
				'name'     => $site_name,
				'item'     => $site_url,
			),
		);
		$posts_page_id = intval( get_option( 'page_for_posts', 0 ) );
		$posts_page    = $posts_page_id > 0 ? get_post( $posts_page_id ) : null;
		if ( $posts_page instanceof WP_Post && 'publish' === $posts_page->post_status ) {
			$breadcrumb_items[] = array(
				'@type'    => 'ListItem',
				'position' => count( $breadcrumb_items ) + 1,
				'name'     => get_the_title( $posts_page_id ),
				'item'     => get_permalink( $posts_page_id ),
			);
		}
		$breadcrumb_items[] = array(
			'@type'    => 'ListItem',
			'position' => count( $breadcrumb_items ) + 1,
			'name'     => $post->post_title,
			'item'     => $post_url,
		);
		$breadcrumb = array(
			'@context'        => 'https://schema.org',
			'@type'           => 'BreadcrumbList',
			'itemListElement' => $breadcrumb_items,
		);
		$schemas[] = wp_json_encode( $breadcrumb, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );

		return $schemas;
	}
}
