<?php
/**
 * Code Snippet JSON import/export.
 *
 * Secure design (rafter-secure-design / ingestion + auth):
 * - Identity: site admin (manage_options) only. Temporary-login sessions are denied by Module handlers.
 * - Parser: json_decode with a depth cap. No XML, YAML, unserialize, or URL fetch.
 * - Uploads: is_uploaded_file + .json allowlist; never stored under the client filename.
 * - Schema: allowlisted snapshot fields only. Foreign post IDs are dropped.
 * - Executable path: Module::importNormalizedSnippet() re-runs save gates (syntax + threat scan).
 *
 * @package CodeSnippet
 */

namespace CodeSnippet;

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

/**
 * Detects, normalizes, and exports snippet JSON packs.
 */
final class ImportExport {

	public const SOURCE = 'wp-powersuite-code-snippet';

	public const SCHEMA_VERSION = 1;

	public const MAX_IMPORT_BYTES = 2097152;

	public const MAX_JSON_DEPTH = 32;

	public const MAX_SNIPPETS = 200;

	public const MAX_CODE_BYTES = 262144;

	public const MAX_TITLE_CHARS = 200;

	public const MAX_NOTES_BYTES = 5120;

	/**
	 * Allowlisted snapshot keys written to native exports and accepted on import.
	 *
	 * @var string[]
	 */
	private const SNAPSHOT_FIELDS = [
		'title',
		'code',
		'code_type',
		'location',
		'location_param',
		'location_selector',
		'location_element_index',
		'custom_shortcode',
		'auto_insert',
		'priority',
		'notes',
		'schedule_enabled',
		'schedule_start',
		'schedule_end',
		'device_type',
		'conditional_enabled',
		'conditional_show',
		'conditional_rules',
		'revisions_enabled',
		'load_as_file',
		'active',
	];

	/**
	 * Build a native export payload.
	 *
	 * @param int[]|null $ids Specific snippet IDs, or null for all.
	 * @return array{schema: int, source: string, generated: string, snippets: array<int, array<string, mixed>>}
	 */
	public function export( ?array $ids = null ): array {
		$args = [
			'post_type'      => 'wpps_code_snippet',
			'post_status'    => [ 'publish', 'draft', 'private', 'pending' ],
			'posts_per_page' => -1,
			'orderby'        => 'modified',
			'order'          => 'DESC',
		];

		if ( $ids !== null ) {
			$ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
			if ( $ids === [] ) {
				return $this->wrapPayload( [] );
			}
			$args['post__in'] = $ids;
			$args['orderby']  = 'post__in';
			$args['order']    = 'ASC';
		}

		$query    = new \WP_Query( $args );
		$snippets = [];
		foreach ( $query->posts as $post ) {
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			$raw = Wpps_Revisions::get_snippet_revision_data( (int) $post->ID );
			if ( $raw === [] ) {
				continue;
			}
			$snippets[] = $this->pickSnapshotFields( $raw );
		}

		return $this->wrapPayload( $snippets );
	}

	/**
	 * Encode an export payload as pretty JSON.
	 *
	 * @param int[]|null $ids Specific snippet IDs, or null for all.
	 */
	public function exportJson( ?array $ids = null ): string {
		$encoded = wp_json_encode( $this->export( $ids ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		return is_string( $encoded ) ? $encoded : '';
	}

	/**
	 * Parse and normalize a JSON pack without persisting.
	 *
	 * @return array{snippets: array<int, array<string, mixed>>, errors: array<int, string>, format: string}
	 */
	public function parse( string $json ): array {
		$result = [
			'snippets' => [],
			'errors'   => [],
			'format'   => '',
		];

		$json = self::normalizeJson( $json );
		if ( $json === '' ) {
			$result['errors'][] = __( 'No import data was provided.', 'wppowersuite-lite' );
			return $result;
		}

		if ( strlen( $json ) > self::MAX_IMPORT_BYTES ) {
			$result['errors'][] = __( 'The import data is too large (limit: 2 MB).', 'wppowersuite-lite' );
			return $result;
		}

		$data = json_decode( $json, true, self::MAX_JSON_DEPTH );
		if ( ! is_array( $data ) || json_last_error() !== JSON_ERROR_NONE ) {
			$result['errors'][] = __( 'The import data is not valid JSON.', 'wppowersuite-lite' );
			return $result;
		}

		$format  = $this->detectFormat( $data );
		$entries = $this->extractEntries( $data, $format );
		if ( $entries === [] ) {
			$result['errors'][] = __( 'No snippets were found in the import data.', 'wppowersuite-lite' );
			return $result;
		}

		$result['format'] = $format;
		if ( count( $entries ) > self::MAX_SNIPPETS ) {
			$result['errors'][] = sprintf(
				/* translators: %d: maximum number of snippets. */
				__( 'Only the first %d snippets were imported (limit).', 'wppowersuite-lite' ),
				self::MAX_SNIPPETS
			);
			$entries = array_slice( $entries, 0, self::MAX_SNIPPETS );
		}

		foreach ( $entries as $index => $entry ) {
			if ( ! is_array( $entry ) ) {
				$result['errors'][] = sprintf(
					/* translators: %d: 1-based row number. */
					__( 'Snippet %d was skipped: invalid entry.', 'wppowersuite-lite' ),
					$index + 1
				);
				continue;
			}

			$normalized = $this->normalizeEntry( $entry, $format );
			if ( is_string( $normalized ) ) {
				$result['errors'][] = sprintf(
					/* translators: 1: 1-based row number, 2: error message. */
					__( 'Snippet %1$d was skipped: %2$s', 'wppowersuite-lite' ),
					$index + 1,
					$normalized
				);
				continue;
			}

			$result['snippets'][] = $normalized;
		}

		if ( $result['snippets'] === [] && $result['errors'] === [] ) {
			$result['errors'][] = __( 'No snippets were found in the import data.', 'wppowersuite-lite' );
		}

		return $result;
	}

	/**
	 * Import a JSON pack through the module persist gate.
	 *
	 * @return array{imported: int, skipped: array<int, string>, errors: array<int, string>, ids: array<int, int>, format: string}
	 */
	public function import( string $json, Module $module, bool $as_inactive ): array {
		$parsed = $this->parse( $json );
		$result = [
			'imported' => 0,
			'skipped'  => [],
			'errors'   => $parsed['errors'],
			'ids'      => [],
			'format'   => $parsed['format'],
		];

		foreach ( $parsed['snippets'] as $snippet ) {
			$label = (string) ( $snippet['title'] !== '' ? $snippet['title'] : __( 'Untitled Snippet', 'wppowersuite-lite' ) );
			$saved = $module->importNormalizedSnippet( $snippet, $as_inactive );
			if ( is_wp_error( $saved ) ) {
				$result['skipped'][] = sprintf(
					/* translators: 1: snippet title, 2: error message. */
					__( '“%1$s” was skipped: %2$s', 'wppowersuite-lite' ),
					$label,
					$saved->get_error_message()
				);
				continue;
			}

			$result['ids'][] = $saved;
			++$result['imported'];
		}

		if ( $result['imported'] > 0 ) {
			$module->bustSnippetCachesAfterImport();
		}

		return $result;
	}

	/**
	 * Read and validate an uploaded JSON import file.
	 *
	 * @param array<string, mixed> $file One entry from $_FILES.
	 * @return array{json: string, error: string}
	 */
	public static function readUploadedFile( array $file ): array {
		$tmp_name = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
		$name     = isset( $file['name'] ) ? (string) $file['name'] : '';
		$err_code = isset( $file['error'] ) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;

		if ( $err_code === UPLOAD_ERR_NO_FILE || $tmp_name === '' ) {
			return [
				'json'  => '',
				'error' => '',
			];
		}

		if ( $err_code !== UPLOAD_ERR_OK || ! is_uploaded_file( $tmp_name ) ) {
			return [
				'json'  => '',
				'error' => __( 'The import file could not be uploaded. Please try again.', 'wppowersuite-lite' ),
			];
		}

		$size = (int) ( $file['size'] ?? 0 );
		if ( $size <= 0 || $size > self::MAX_IMPORT_BYTES ) {
			return [
				'json'  => '',
				'error' => __( 'The import file is empty or too large (limit: 2 MB).', 'wppowersuite-lite' ),
			];
		}

		$filetype = wp_check_filetype( $name, [ 'json' => 'application/json' ] );
		if ( $filetype['ext'] !== 'json' ) {
			return [
				'json'  => '',
				'error' => __( 'Only .json files can be imported.', 'wppowersuite-lite' ),
			];
		}

		$contents = file_get_contents( $tmp_name ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- validated local upload.
		if ( ! is_string( $contents ) || $contents === '' ) {
			return [
				'json'  => '',
				'error' => __( 'The import file could not be read.', 'wppowersuite-lite' ),
			];
		}

		return [
			'json'  => $contents,
			'error' => '',
		];
	}

	/**
	 * Trim whitespace and a UTF-8 BOM.
	 */
	public static function normalizeJson( string $json ): string {
		$json = trim( $json );
		if ( str_starts_with( $json, "\xEF\xBB\xBF" ) ) {
			$json = substr( $json, 3 );
		}

		return trim( $json );
	}

	/**
	 * Admin-post URL for exporting all or one snippet.
	 */
	public static function exportUrl( ?int $id = null ): string {
		$args = [ 'action' => 'wpps_cs_export' ];
		if ( $id !== null && $id > 0 ) {
			$args['id'] = $id;
		}

		return wp_nonce_url( add_query_arg( $args, admin_url( 'admin-post.php' ) ), 'wpps_cs_export' );
	}

	/**
	 * @param array<int, array<string, mixed>> $snippets
	 * @return array{schema: int, source: string, generated: string, snippets: array<int, array<string, mixed>>}
	 */
	private function wrapPayload( array $snippets ): array {
		return [
			'schema'    => self::SCHEMA_VERSION,
			'source'    => self::SOURCE,
			'generated' => gmdate( 'Y-m-d H:i:s' ),
			'snippets'  => $snippets,
		];
	}

	/**
	 * @param array<string, mixed> $raw
	 * @return array<string, mixed>
	 */
	private function pickSnapshotFields( array $raw ): array {
		$out = [];
		foreach ( self::SNAPSHOT_FIELDS as $key ) {
			if ( array_key_exists( $key, $raw ) ) {
				$out[ $key ] = $raw[ $key ];
			}
		}

		return $out;
	}

	/**
	 * @param array<string, mixed>|array<int, mixed> $data
	 */
	private function detectFormat( array $data ): string {
		if ( ( $data['source'] ?? '' ) === self::SOURCE ) {
			return 'powersuite';
		}

		$generator = isset( $data['generator'] ) ? (string) $data['generator'] : '';
		if ( $generator !== '' && stripos( $generator, 'code snippets' ) !== false ) {
			return 'code-snippets';
		}

		$entries = $this->rawSnippetList( $data );
		$first   = $this->firstObject( $entries );
		if ( $first !== null ) {
			if ( isset( $first['code_type'] ) || isset( $first['insert_location'] ) ) {
				return 'wpcode';
			}
			if ( isset( $first['scope'] ) && ( isset( $first['name'] ) || isset( $first['code'] ) ) ) {
				return 'code-snippets';
			}
			if ( isset( $first['title'] ) || isset( $first['name'] ) || isset( $first['code'] ) ) {
				return isset( $first['location'] ) && is_string( $first['location'] ) && $this->isKnownLocation( $first['location'] )
					? 'powersuite'
					: 'generic';
			}
		}

		return '';
	}

	/**
	 * @param array<string, mixed>|array<int, mixed> $data
	 * @return array<int, mixed>
	 */
	private function extractEntries( array $data, string $format ): array {
		if ( $format === '' ) {
			return [];
		}

		return $this->rawSnippetList( $data );
	}

	/**
	 * @param array<string, mixed>|array<int, mixed> $data
	 * @return array<int, mixed>
	 */
	private function rawSnippetList( array $data ): array {
		if ( isset( $data['snippets'] ) && is_array( $data['snippets'] ) ) {
			return array_values( $data['snippets'] );
		}

		if ( array_is_list( $data ) ) {
			return $data;
		}

		if ( isset( $data['title'] ) || isset( $data['name'] ) || isset( $data['code'] ) ) {
			return [ $data ];
		}

		return [];
	}

	/**
	 * @param array<int, mixed> $entries
	 * @return array<string, mixed>|null
	 */
	private function firstObject( array $entries ): ?array {
		foreach ( $entries as $entry ) {
			if ( is_array( $entry ) && ! array_is_list( $entry ) ) {
				return $entry;
			}
			if ( is_array( $entry ) && ( isset( $entry['title'] ) || isset( $entry['name'] ) || isset( $entry['code'] ) ) ) {
				return $entry;
			}
		}

		return null;
	}

	/**
	 * @param array<string, mixed> $entry
	 * @return array<string, mixed>|string Normalized snapshot or error message.
	 */
	private function normalizeEntry( array $entry, string $format ) {
		$title = isset( $entry['title'] ) ? (string) $entry['title'] : (string) ( $entry['name'] ?? '' );
		$title = sanitize_text_field( $title );
		if ( function_exists( 'mb_substr' ) ) {
			$title = mb_substr( $title, 0, self::MAX_TITLE_CHARS );
		} else {
			$title = substr( $title, 0, self::MAX_TITLE_CHARS );
		}

		$code = isset( $entry['code'] ) ? (string) $entry['code'] : '';
		if ( strlen( $code ) > self::MAX_CODE_BYTES ) {
			return __( 'code is too large (limit: 256 KB).', 'wppowersuite-lite' );
		}

		$type     = $this->mapCodeType( $entry, $format, $code );
		$location = $this->mapLocation( $entry, $format, $type );

		$notes = '';
		foreach ( [ 'notes', 'note', 'desc', 'description' ] as $note_key ) {
			if ( isset( $entry[ $note_key ] ) && is_scalar( $entry[ $note_key ] ) && (string) $entry[ $note_key ] !== '' ) {
				$notes = (string) $entry[ $note_key ];
				break;
			}
		}
		if ( isset( $entry['tags'] ) && is_array( $entry['tags'] ) ) {
			$tags = [];
			foreach ( $entry['tags'] as $tag ) {
				if ( is_scalar( $tag ) && (string) $tag !== '' ) {
					$tags[] = sanitize_text_field( (string) $tag );
				}
			}
			$tags = array_filter( $tags );
			if ( $tags !== [] ) {
				$tag_line = sprintf(
					/* translators: %s: comma-separated tags. */
					__( 'Tags: %s', 'wppowersuite-lite' ),
					implode( ', ', $tags )
				);
				$notes = $notes === '' ? $tag_line : $notes . "\n" . $tag_line;
			}
		}
		$notes = sanitize_textarea_field( $notes );
		if ( strlen( $notes ) > self::MAX_NOTES_BYTES ) {
			$notes = substr( $notes, 0, self::MAX_NOTES_BYTES );
		}

		$auto_insert = true;
		if ( array_key_exists( 'auto_insert', $entry ) ) {
			$auto_insert = $this->toBool( $entry['auto_insert'] );
		}
		if ( $format !== 'powersuite' && array_key_exists( 'auto_insert', $entry ) && ! $auto_insert ) {
			$location = 'shortcode';
		}
		if ( $location === 'shortcode' ) {
			$auto_insert = false;
		}

		$custom = '';
		if ( isset( $entry['custom_shortcode'] ) ) {
			$custom = (string) $entry['custom_shortcode'];
		} elseif ( $location === 'shortcode' && isset( $entry['shortcode'] ) ) {
			$custom = (string) $entry['shortcode'];
		}

		$device = isset( $entry['device_type'] ) ? sanitize_key( (string) $entry['device_type'] ) : 'all';
		if ( ! in_array( $device, [ 'all', 'desktop', 'mobile', 'tablet' ], true ) ) {
			$device = 'all';
		}

		$rules = [];
		if ( isset( $entry['conditional_rules'] ) && is_array( $entry['conditional_rules'] ) ) {
			$rules = $entry['conditional_rules'];
		}

		$active = array_key_exists( 'active', $entry ) ? $this->toBool( $entry['active'] ) : false;

		return [
			'title'                  => $title,
			'code'                   => $code,
			'code_type'              => $type,
			'location'               => $location,
			'location_param'         => isset( $entry['location_param'] ) ? max( 1, absint( $entry['location_param'] ) ) : 1,
			'location_selector'      => isset( $entry['location_selector'] ) ? (string) $entry['location_selector'] : '',
			'location_element_index' => isset( $entry['location_element_index'] ) && $entry['location_element_index'] !== ''
				? (string) max( 0, (int) $entry['location_element_index'] )
				: '',
			'custom_shortcode'       => $custom,
			'auto_insert'            => $auto_insert,
			'priority'               => isset( $entry['priority'] ) ? absint( $entry['priority'] ) : 10,
			'notes'                  => $notes,
			'schedule_enabled'       => $this->toBool( $entry['schedule_enabled'] ?? false ),
			'schedule_start'         => isset( $entry['schedule_start'] ) ? sanitize_text_field( (string) $entry['schedule_start'] ) : '',
			'schedule_end'           => isset( $entry['schedule_end'] ) ? sanitize_text_field( (string) $entry['schedule_end'] ) : '',
			'device_type'            => $device,
			'conditional_enabled'    => $this->toBool( $entry['conditional_enabled'] ?? false ),
			'conditional_show'       => ( isset( $entry['conditional_show'] ) && (string) $entry['conditional_show'] === '0' ) ? '0' : '1',
			'conditional_rules'      => $rules,
			'revisions_enabled'      => $this->toBool( $entry['revisions_enabled'] ?? false ),
			'load_as_file'           => $this->toBool( $entry['load_as_file'] ?? false ),
			'active'                 => $active,
		];
	}

	/**
	 * @param array<string, mixed> $entry
	 */
	private function mapCodeType( array $entry, string $format, string $code ): string {
		$raw = '';
		if ( isset( $entry['code_type'] ) ) {
			$raw = sanitize_key( (string) $entry['code_type'] );
		}

		$map = [
			'php'           => 'php',
			'php_snippet'   => 'php',
			'js'            => 'js',
			'javascript'    => 'js',
			'js_snippet'    => 'js',
			'css'           => 'css',
			'css_snippet'   => 'css',
			'html'          => 'html',
			'html_snippet'  => 'html',
			'text'          => 'html',
			'universal'     => 'html',
		];
		if ( $raw !== '' && isset( $map[ $raw ] ) ) {
			return $map[ $raw ];
		}

		if ( $format === 'code-snippets' ) {
			return $this->guessCodeType( $code, 'php' );
		}

		if ( $raw !== '' && in_array( $raw, [ 'html', 'css', 'js', 'php' ], true ) ) {
			return $raw;
		}

		return $this->guessCodeType( $code, 'html' );
	}

	private function guessCodeType( string $code, string $fallback ): string {
		$trim = ltrim( $code );
		if ( $trim === '' ) {
			return $fallback;
		}
		if ( preg_match( '/^<\?php\b/i', $trim ) ) {
			return 'php';
		}
		if ( preg_match( '/^(body|html|:root|@media|@import|\.|#|\/\*)/i', $trim ) ) {
			return 'css';
		}
		if ( preg_match( '/^(function\s+|const\s+|let\s+|var\s+|document\.|window\.|jQuery\(|\$\()/', $trim ) ) {
			return 'js';
		}

		return $fallback;
	}

	/**
	 * @param array<string, mixed> $entry
	 */
	private function mapLocation( array $entry, string $format, string $type ): string {
		if ( $format === 'code-snippets' ) {
			$scope = isset( $entry['scope'] ) ? sanitize_key( (string) $entry['scope'] ) : 'global';
			$map   = [
				'global'      => 'run_everywhere',
				'admin'       => 'admin_only',
				'front-end'   => 'frontend_only',
				'frontend'    => 'frontend_only',
				'front_end'   => 'frontend_only',
				'single-use'  => 'shortcode',
				'single_use'  => 'shortcode',
				'content'     => 'frontend_only',
			];
			$mapped = $map[ $scope ] ?? 'run_everywhere';
			if ( $type !== 'php' && in_array( $mapped, Module::getPhpOnlyLocations(), true ) ) {
				return $mapped === 'admin_only' ? 'admin_footer' : 'footer';
			}

			return $mapped;
		}

		$raw = '';
		if ( isset( $entry['location'] ) ) {
			$raw = (string) $entry['location'];
		} elseif ( isset( $entry['insert_location'] ) ) {
			$raw = (string) $entry['insert_location'];
		}
		$slug = sanitize_key( $raw );
		if ( $this->isKnownLocation( $slug ) ) {
			return $slug;
		}

		$map = [
			'site_header'         => 'header',
			'header'              => 'header',
			'head'                => 'header',
			'site_footer'         => 'footer',
			'footer'              => 'footer',
			'site_body'           => 'body',
			'body'                => 'body',
			'body_open'           => 'body',
			'admin_header'        => 'admin_header',
			'admin_footer'        => 'admin_footer',
			'admin_only'          => $type === 'php' ? 'admin_only' : 'admin_footer',
			'admin'               => $type === 'php' ? 'admin_only' : 'admin_footer',
			'frontend_only'       => 'frontend_only',
			'frontend'            => $type === 'php' ? 'frontend_only' : 'footer',
			'front'               => $type === 'php' ? 'frontend_only' : 'footer',
			'front_end'           => $type === 'php' ? 'frontend_only' : 'footer',
			'everywhere'          => $type === 'php' ? 'run_everywhere' : 'footer',
			'site_wide'           => $type === 'php' ? 'run_everywhere' : 'footer',
			'sitewide'            => $type === 'php' ? 'run_everywhere' : 'footer',
			'shortcode'           => 'shortcode',
			'before_content'      => 'before_content',
			'after_content'       => 'after_content',
			'before_post'         => 'before_content',
			'after_post'          => 'after_content',
			'before_post_content' => 'before_content',
			'after_post_content'  => 'after_content',
		];

		if ( $slug !== '' && isset( $map[ $slug ] ) ) {
			return $map[ $slug ];
		}

		return $type === 'php' ? 'run_everywhere' : 'footer';
	}

	private function isKnownLocation( string $slug ): bool {
		return $slug !== '' && in_array( $slug, Module::getAllLocationSlugs(), true );
	}

	private function toBool( mixed $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}
		if ( is_int( $value ) || is_float( $value ) ) {
			return (int) $value === 1;
		}
		if ( is_string( $value ) ) {
			return in_array( strtolower( $value ), [ '1', 'true', 'yes', 'on' ], true );
		}

		return false;
	}
}
