<?php
/**
 * CPT Builder import/export.
 *
 * Produces schema-versioned JSON exports and safely imports them back, fully
 * re-sanitizing and re-validating every field through the Validator.
 *
 * @package CptBuilder
 * @since 1.0.0
 */

namespace CptBuilder;

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

/**
 * Handles export and import of CPT definitions.
 */
final class ImportExport {

	/**
	 * Identifier embedded in exports so imports can recognise the format.
	 */
	private const SOURCE = 'wp-powersuite-cpt-builder';

	/**
	 * Maximum accepted import payload size in bytes (1 MB).
	 */
	public const MAX_IMPORT_BYTES = 1048576;

	/**
	 * Maximum JSON nesting depth accepted on import.
	 */
	public const MAX_JSON_DEPTH = 32;

	private Storage $storage;

	private Validator $validator;

	public function __construct( Storage $storage, Validator $validator ) {
		$this->storage   = $storage;
		$this->validator = $validator;
	}

	/**
	 * Build an export payload for one or all post types.
	 *
	 * @param string|null $key Specific key, or null to export everything.
	 * @return array<string, mixed>
	 */
	public function export( ?string $key = null ): array {
		$definitions = $this->storage->all();

		if ( $key !== null ) {
			$definitions = isset( $definitions[ $key ] ) ? [ $key => $definitions[ $key ] ] : [];
		}

		return [
			'schema'     => Config::SCHEMA_VERSION,
			'source'     => self::SOURCE,
			'generated'  => gmdate( 'Y-m-d H:i:s' ),
			'post_types' => array_values( $definitions ),
		];
	}

	/**
	 * Encode an export payload as pretty JSON.
	 *
	 * @param string|null $key Specific key, or null for all.
	 */
	public function exportJson( ?string $key = null ): string {
		return (string) wp_json_encode( $this->export( $key ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
	}

	/**
	 * Import definitions from a JSON string.
	 *
	 * @param string $json               Raw JSON.
	 * @param bool   $overwrite          Whether to overwrite existing keys.
	 * @param bool   $import_as_disabled Whether to force imported types disabled.
	 * @return array{imported: int, skipped: array<int, string>, errors: array<int, string>}
	 */
	public function import( string $json, bool $overwrite, bool $import_as_disabled ): array {
		$result = [
			'imported' => 0,
			'skipped'  => [],
			'errors'   => [],
		];

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

		if ( strlen( $json ) > self::MAX_IMPORT_BYTES ) {
			$result['errors'][] = __( 'The import data is too large (limit: 1 MB).', 'wp-powersuite' );
			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.', 'wp-powersuite' );
			return $result;
		}

		$entries = $this->extractEntries( $data );
		if ( $entries === [] ) {
			$result['errors'][] = __( 'No post type definitions were found in the import data.', 'wp-powersuite' );
			return $result;
		}

		foreach ( $entries as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$key = $this->validator->sanitizeKey( (string) ( $entry['key'] ?? '' ) );
			if ( $key === '' ) {
				$result['errors'][] = __( 'Skipped an entry with a missing or invalid key.', 'wp-powersuite' );
				continue;
			}

			$already_exists = $this->storage->exists( $key );
			if ( $already_exists && ! $overwrite ) {
				$result['skipped'][] = $key;
				continue;
			}

			$existing   = $already_exists ? $this->storage->get( $key ) : null;
			$definition = $this->validator->buildDefinition( $entry, $existing );

			if ( $import_as_disabled ) {
				$definition['enabled'] = false;
			}

			$errors = $this->validator->validateDefinition( $definition, $already_exists, $already_exists ? $key : null );
			if ( $errors !== [] ) {
				$result['errors'][] = sprintf(
					/* translators: 1: post type key, 2: error message. */
					__( '“%1$s” was skipped: %2$s', 'wp-powersuite' ),
					$key,
					implode( ' ', $errors )
				);
				continue;
			}

			$this->storage->save( $key, $definition );
			++$result['imported'];
		}

		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} JSON contents, or an error message.
	 */
	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.', 'wp-powersuite' ),
			];
		}

		$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: 1 MB).', 'wp-powersuite' ),
			];
		}

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

		$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.', 'wp-powersuite' ),
			];
		}

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

	/**
	 * Normalize a raw JSON string: trim whitespace and strip a UTF-8 BOM.
	 *
	 * @param string $json Raw JSON.
	 */
	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 );
	}

	/**
	 * Extract the list of raw definition entries from decoded import data.
	 *
	 * Accepts both the wrapped export format and a bare list of definitions.
	 *
	 * @param array<string, mixed>|array<int, mixed> $data Decoded JSON.
	 * @return array<int, mixed>
	 */
	private function extractEntries( array $data ): array {
		if ( isset( $data['post_types'] ) && is_array( $data['post_types'] ) ) {
			return array_values( $data['post_types'] );
		}

		// Bare list of definitions.
		if ( array_is_list( $data ) ) {
			return $data;
		}

		// Single definition object.
		if ( isset( $data['key'] ) ) {
			return [ $data ];
		}

		return [];
	}
}
