<?php
/**
 * Legacy product-link importer.
 *
 * @package Affilun\Application\Migration
 */

declare(strict_types=1);

namespace Affilun\Application\Migration;

/**
 * Builds and applies explicit legacy product-link rewrite plans.
 */
final class LegacyProductLinkImporter {

	/**
	 * Neutral legacy shortcode tags this importer can safely rewrite.
	 *
	 * @var list<string>
	 */
	private const SHORTCODE_TAGS = array(
		'product_link',
		'product-card',
		'affiliate_product',
		'affiliate-card',
		'legacy_product_link',
	);

	/**
	 * Preview rewriteable legacy shortcode/block shapes.
	 *
	 * @param list<PostScanItem> $posts Posts.
	 *
	 * @return array{posts_scanned:int,changes:list<array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}>}
	 */
	public function preview( array $posts ): array {
		$changes = array();
		foreach ( $posts as $post ) {
			$changes = array_merge( $changes, $this->changesForPost( $post ) );
		}

		return array(
			'posts_scanned' => count( $posts ),
			'changes'       => $changes,
		);
	}

	/**
	 * Apply a preview plan and update changed posts.
	 *
	 * @param list<PostScanItem> $posts       Posts.
	 * @param callable           $postUpdater Post updater callback.
	 *
	 * @phpstan-param callable(int,string): bool $postUpdater
	 *
	 * @return array{posts_scanned:int,changes:list<array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}>,updated_posts:int}
	 */
	public function apply( array $posts, callable $postUpdater ): array {
		$preview       = $this->preview( $posts );
		$changesByPost = array();
		foreach ( $preview['changes'] as $change ) {
			$postId = $change['post_id'];
			if ( ! array_key_exists( $postId, $changesByPost ) ) {
				$changesByPost[ $postId ] = array();
			}
			$changesByPost[ $postId ][] = $change;
		}

		$updated = 0;
		foreach ( $posts as $post ) {
			$postChanges = $changesByPost[ $post->id() ] ?? array();
			if ( $postChanges === array() ) {
				continue;
			}

			$newContent = $this->rewriteContent( $post->content(), $postChanges );
			if ( $newContent === $post->content() ) {
				continue;
			}

			if ( $postUpdater( $post->id(), $newContent ) ) {
				++$updated;
			}
		}

		return array(
			'posts_scanned' => $preview['posts_scanned'],
			'changes'       => $preview['changes'],
			'updated_posts' => $updated,
		);
	}

	/**
	 * Find rewriteable changes for one post.
	 *
	 * @param PostScanItem $post Post.
	 *
	 * @return list<array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}>
	 */
	private function changesForPost( PostScanItem $post ): array {
		$changes = array();
		foreach ( self::SHORTCODE_TAGS as $tag ) {
			$changes = array_merge( $changes, $this->shortcodeChangesForPost( $post, $tag ) );
		}
		$changes = array_merge( $changes, $this->blockChangesForPost( $post ) );

		usort(
			$changes,
			static fn ( array $a, array $b ): int => $a['offset'] <=> $b['offset']
		);

		return $changes;
	}

	/**
	 * Find rewriteable legacy shortcode changes.
	 *
	 * @param PostScanItem $post Post.
	 * @param string       $tag  Shortcode tag.
	 *
	 * @return list<array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}>
	 */
	private function shortcodeChangesForPost( PostScanItem $post, string $tag ): array {
		$pattern = '/\[\s*' . preg_quote( $tag, '/' ) . '\b([^\]]*)\](?:.*?\[\/\s*'
			. preg_quote( $tag, '/' ) . '\s*\])?/is';
		$changes = array();
		if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) === false ) {
			return array();
		}

		foreach ( $found[0] as $index => $match ) {
			$attrs       = self::parseAttributes( (string) ( $found[1][ $index ][0] ?? '' ) );
			$replacement = $this->shortcodeReplacement( $attrs );
			if ( $replacement === null ) {
				continue;
			}

			$changes[] = $this->change(
				$post,
				'shortcode',
				$tag,
				(int) $match[1],
				(string) $match[0],
				$replacement,
				95
			);
		}

		return $changes;
	}

	/**
	 * Find rewriteable legacy block comment changes.
	 *
	 * @param PostScanItem $post Post.
	 *
	 * @return list<array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}>
	 */
	private function blockChangesForPost( PostScanItem $post ): array {
		$pattern = '/<!--\s+wp:legacy-product-link\/(card|product-card|grid)\s*({.*?})?\s*'
			. '(?:\/-->|-->.*?<!--\s+\/wp:legacy-product-link\/\1\s+-->)/is';
		$changes = array();
		if ( preg_match_all( $pattern, $post->content(), $found, PREG_OFFSET_CAPTURE ) === false ) {
			return array();
		}

		foreach ( $found[0] as $index => $match ) {
			$shape       = strtolower( (string) ( $found[1][ $index ][0] ?? '' ) );
			$json        = (string) ( $found[2][ $index ][0] ?? '' );
			$replacement = $this->blockReplacement( $shape, $json );
			if ( $replacement === null ) {
				continue;
			}

			$changes[] = $this->change(
				$post,
				'block',
				'legacy-product-link/' . $shape,
				(int) $match[1],
				(string) $match[0],
				$replacement,
				98
			);
		}

		return $changes;
	}

	/**
	 * Build a shortcode replacement from parsed attributes.
	 *
	 * @param array<string,string> $attrs Attributes.
	 */
	private function shortcodeReplacement( array $attrs ): ?string {
		$ids = self::positiveIntList( $attrs['product_ids'] ?? $attrs['ids'] ?? '' );
		if ( count( $ids ) > 1 ) {
			return implode(
				"\n",
				array_map(
					static fn ( int $id ): string => '[affilun_product_card product_id="' . (string) $id . '"]',
					$ids
				)
			);
		}

		$id = self::positiveInt( $attrs['affilun_product_id'] ?? $attrs['product_id'] ?? $attrs['id'] ?? null );
		if ( $id === null && count( $ids ) === 1 ) {
			$id = $ids[0];
		}
		if ( $id === null ) {
			return null;
		}

		return '[affilun_product_card product_id="' . (string) $id . '"]';
	}

	/**
	 * Build a block replacement from a legacy block JSON payload.
	 *
	 * @param string $shape Block shape.
	 * @param string $json  Attribute JSON.
	 */
	private function blockReplacement( string $shape, string $json ): ?string {
		$attrs = self::decodeJsonObject( $json );
		if ( $shape === 'grid' ) {
			$ids = self::positiveIntList( $attrs['productIds'] ?? $attrs['product_ids'] ?? $attrs['ids'] ?? '' );
			if ( $ids === array() ) {
				return null;
			}
			return implode(
				"\n",
				array_map(
					static fn ( int $id ): string => '<!-- wp:affilun/product-card {"productId":' . (string) $id . '} /-->',
					$ids
				)
			);
		}

		$id = self::positiveInt( $attrs['productId'] ?? $attrs['product_id'] ?? $attrs['id'] ?? null );
		if ( $id === null ) {
			return null;
		}

		return '<!-- wp:affilun/product-card {"productId":' . (string) $id . '} /-->';
	}

	/**
	 * Rewrite content using non-overlapping changes from the end backward.
	 *
	 * @param string                    $content Content.
	 * @param list<array<string,mixed>> $changes Changes.
	 */
	public function rewriteContent( string $content, array $changes ): string {
		usort(
			$changes,
			static fn ( array $a, array $b ): int => (int) $b['offset'] <=> (int) $a['offset']
		);

		foreach ( $changes as $change ) {
			$offset      = (int) $change['offset'];
			$original    = is_string( $change['original'] ?? null ) ? $change['original'] : '';
			$replacement = is_string( $change['replacement'] ?? null ) ? $change['replacement'] : '';
			if ( $original === '' || substr( $content, $offset, strlen( $original ) ) !== $original ) {
				continue;
			}
			$content = substr_replace( $content, $replacement, $offset, strlen( $original ) );
		}

		return $content;
	}

	/**
	 * Build one change row.
	 *
	 * @param PostScanItem $post        Post.
	 * @param string       $kind        Kind.
	 * @param string       $source      Source.
	 * @param int          $offset      Offset.
	 * @param string       $original    Original content.
	 * @param string       $replacement Replacement content.
	 * @param int          $confidence  Confidence.
	 *
	 * @return array{post_id:int,post_title:string,kind:string,source:string,offset:int,original:string,replacement:string,confidence:int}
	 */
	private function change(
		PostScanItem $post,
		string $kind,
		string $source,
		int $offset,
		string $original,
		string $replacement,
		int $confidence
	): array {
		return array(
			'post_id'     => $post->id(),
			'post_title'  => $post->title(),
			'kind'        => $kind,
			'source'      => $source,
			'offset'      => max( 0, $offset ),
			'original'    => $original,
			'replacement' => $replacement,
			'confidence'  => max( 0, min( 100, $confidence ) ),
		);
	}

	/**
	 * Parse shortcode attributes.
	 *
	 * @param string $raw Raw attribute string.
	 *
	 * @return array<string,string>
	 */
	private static function parseAttributes( string $raw ): array {
		$attrs   = array();
		$pattern = '/([a-zA-Z0-9_\-]+)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s\]]+)/';
		if ( preg_match_all( $pattern, $raw, $found, PREG_SET_ORDER ) === false ) {
			return array();
		}

		foreach ( $found as $match ) {
			$key   = strtolower( (string) $match[1] );
			$value = trim( (string) $match[2], " \t\n\r\0\x0B\"'" );
			if ( $key !== '' ) {
				$attrs[ $key ] = $value;
			}
		}

		return $attrs;
	}

	/**
	 * Decode a JSON object.
	 *
	 * @param string $json JSON.
	 *
	 * @return array<string,mixed>
	 */
	private static function decodeJsonObject( string $json ): array {
		if ( trim( $json ) === '' ) {
			return array();
		}

		$decoded = json_decode( $json, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * Parse positive int.
	 *
	 * @param mixed $value Value.
	 */
	private static function positiveInt( mixed $value ): ?int {
		if ( is_int( $value ) && $value > 0 ) {
			return $value;
		}
		if ( ! is_scalar( $value ) ) {
			return null;
		}
		$value = trim( (string) $value );
		return ctype_digit( $value ) && (int) $value > 0 ? (int) $value : null;
	}

	/**
	 * Parse positive int list.
	 *
	 * @param mixed $value Value.
	 *
	 * @return list<int>
	 */
	private static function positiveIntList( mixed $value ): array {
		$parts = is_array( $value ) ? $value : explode( ',', is_scalar( $value ) ? (string) $value : '' );
		$ids   = array();
		foreach ( $parts as $part ) {
			$id = self::positiveInt( $part );
			if ( $id !== null && ! in_array( $id, $ids, true ) ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}
}
