<?php
/**
 * Config export / import for BW AI Schema Pro.
 *
 * Exports a site's schema configuration as JSON and imports it back — for a
 * backup before a big change, to move settings between sites, or to keep the
 * configuration alongside the project.
 *
 * The plugin owns ~51 `bw_schema_*` options and they are NOT equally portable.
 * The tier map below is the heart of this module:
 *
 *   config        — portable settings. Exported and imported by default.
 *   site_specific — post IDs / user IDs / CPT slugs that mean nothing on another
 *                   site. Exported in their own block, imported only on request.
 *   authors       — personal data (names, emails, bios). Export is opt-in.
 *   never         — internal state and logs. Excluded in both directions.
 *
 * Security: import only ever writes keys present in the tier map, so an uploaded
 * file cannot set arbitrary WordPress options. Every value is sanitized by shape
 * before it is stored.
 *
 * @package BW_AI_Schema_Pro
 * @since 2.6.0
 */

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

class BW_Schema_Config_IO {

	/**
	 * Format identifier written into (and required by) every file.
	 */
	const FORMAT = 'bw-ai-schema-pro/config';

	/**
	 * Highest format version this build understands.
	 */
	const FORMAT_VERSION = 1;

	/**
	 * Single-slot undo snapshot.
	 */
	const BACKUP_OPTION = 'bw_schema_config_backup';

	/**
	 * Option name → tier.
	 *
	 * This is an ALLOWLIST. Import will not touch a key that isn't here, which is
	 * what stops a crafted file from writing `active_plugins` or similar.
	 *
	 * @return array
	 */
	public static function get_tier_map() {
		$map = array(
			// --- config: portable settings -------------------------------
			'bw_schema_organization'          => 'config',
			'bw_schema_organization_override' => 'config',
			'bw_schema_org_description'       => 'config',
			'bw_schema_business_type'         => 'config',
			'bw_schema_schema_org_type'       => 'config',
			'bw_schema_address'               => 'config',
			'bw_schema_contact_points'        => 'config',
			'bw_schema_locations'             => 'config',
			'bw_schema_service_areas'         => 'config',
			'bw_schema_accommodation'         => 'config',
			'bw_schema_post_type_defaults'    => 'config',
			'bw_schema_taxonomy_mappings'     => 'config',
			'bw_schema_supported_post_types'  => 'config',
			'bw_schema_price_range'           => 'config',
			'bw_schema_enable_price_range'    => 'config',
			'bw_schema_awards'                => 'config',
			'bw_schema_org_awards'            => 'config',
			'bw_schema_certifications'        => 'config',
			'bw_schema_employee_count'        => 'config',
			'bw_schema_founding_date'         => 'config',
			'bw_schema_org_founding_date'     => 'config',
			'bw_schema_publishing_principles' => 'config',
			'bw_schema_fact_checking_policy'  => 'config',
			'bw_schema_disable_sources'       => 'config',
			'bw_schema_output_org_homepage'   => 'config',
			'bw_schema_enable_schema'         => 'config',
			'bw_schema_enable_breadcrumbs'    => 'config',
			'bw_schema_enable_cache'          => 'config',
			'bw_schema_enable_sitelinks_search' => 'config',
			'bw_schema_enable_security_logging' => 'config',
			'bw_schema_author_box_enabled'    => 'config',
			'bw_schema_author_box_position'   => 'config',
			'bw_schema_author_box_post_types' => 'config',
			'bw_schema_metabox_position'      => 'config',
			'bw_schema_metabox_default_state' => 'config',
			'bw_schema_metabox_post_types'    => 'config',
			'bw_schema_redirect_author_archives' => 'config',
			'bw_schema_job_title_meta_key'    => 'config',
			'bw_schema_use_custom_authors'    => 'config',
			'bw_schema_settings'              => 'config',

			// --- site_specific: IDs and slugs local to one install --------
			// page_mappings holds POST IDs (verified live: {"about":123,...}).
			'bw_schema_page_mappings'         => 'site_specific',
			'bw_schema_team_post_type'        => 'site_specific',
			'bw_schema_room_post_type'        => 'site_specific',
			'bw_schema_plugin_default_author' => 'site_specific',

			// --- authors: personal data ----------------------------------
			// `founders` is the legacy pre-team-CPT option and holds real
			// people's names, so it belongs here rather than in config.
			'bw_schema_custom_authors'        => 'authors',
			'bw_schema_external_authors'      => 'authors',
			'bw_schema_founders'              => 'authors',
		);

		return apply_filters( 'bw_schema_config_io_tier_map', $map );
	}

	/**
	 * Option keys in a tier.
	 *
	 * @param string $tier config | site_specific | authors.
	 * @return string[]
	 */
	public static function get_keys( $tier ) {
		return array_keys( array_filter(
			self::get_tier_map(),
			function ( $t ) use ( $tier ) {
				return $t === $tier;
			}
		) );
	}

	/**
	 * The blocks a caller may ask for, in file order.
	 *
	 * @return string[]
	 */
	public static function get_blocks() {
		return array( 'config', 'site_specific', 'authors' );
	}

	/* ---------------------------------------------------------------------
	 * Export
	 * ------------------------------------------------------------------ */

	/**
	 * Build the export payload.
	 *
	 * Options that have never been set are omitted rather than exported as
	 * `false` — importing a `false` would otherwise overwrite a real value with
	 * garbage on the destination site.
	 *
	 * @param array $includes Blocks to include. Defaults to config only.
	 * @return array
	 */
	public static function export( $includes = array( 'config' ) ) {
		$includes = array_values( array_intersect( self::get_blocks(), (array) $includes ) );
		if ( empty( $includes ) ) {
			$includes = array( 'config' );
		}

		$payload = array(
			'_meta' => array(
				'format'         => self::FORMAT,
				'format_version' => self::FORMAT_VERSION,
				'plugin_version' => defined( 'BW_SCHEMA_VERSION' ) ? BW_SCHEMA_VERSION : '',
				'exported_at'    => gmdate( 'c' ),
				'site_url'       => home_url( '/' ),
				'includes'       => $includes,
			),
		);

		foreach ( $includes as $block ) {
			$payload[ $block ] = array();
			foreach ( self::get_keys( $block ) as $key ) {
				$value = get_option( $key, null );
				if ( null === $value || false === $value ) {
					continue;
				}
				$payload[ $block ][ $key ] = $value;
			}
		}

		return apply_filters( 'bw_schema_config_export', $payload, $includes );
	}

	/**
	 * Export as pretty JSON.
	 *
	 * @param array $includes Blocks to include.
	 * @return string
	 */
	public static function export_json( $includes = array( 'config' ) ) {
		return wp_json_encode(
			self::export( $includes ),
			JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		);
	}

	/**
	 * Suggested download filename.
	 *
	 * @return string
	 */
	public static function export_filename() {
		$host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
		$host = $host ? preg_replace( '/[^a-z0-9.-]/i', '', $host ) : 'site';

		return 'bw-ai-schema-' . $host . '-' . gmdate( 'Y-m-d' ) . '.json';
	}

	/* ---------------------------------------------------------------------
	 * Validation
	 * ------------------------------------------------------------------ */

	/**
	 * Validate a decoded payload.
	 *
	 * @param mixed $data Decoded JSON.
	 * @return array|WP_Error Normalised payload, or an error.
	 */
	public static function validate( $data ) {
		if ( ! is_array( $data ) ) {
			return new WP_Error( 'bw_schema_bad_json', __( 'That file is not valid JSON.', 'bw-ai-schema-pro' ) );
		}

		if ( empty( $data['_meta'] ) || ! is_array( $data['_meta'] ) ) {
			return new WP_Error( 'bw_schema_no_meta', __( 'That file is missing its _meta block, so it is not a BW AI Schema Pro config file.', 'bw-ai-schema-pro' ) );
		}

		if ( ( $data['_meta']['format'] ?? '' ) !== self::FORMAT ) {
			return new WP_Error(
				'bw_schema_bad_format',
				__( 'That file is not a BW AI Schema Pro config file.', 'bw-ai-schema-pro' )
			);
		}

		$format_version = (int) ( $data['_meta']['format_version'] ?? 0 );
		if ( $format_version > self::FORMAT_VERSION ) {
			return new WP_Error(
				'bw_schema_future_format',
				sprintf(
					/* translators: 1: file format version, 2: supported version */
					__( 'That file uses config format version %1$d, but this plugin only understands version %2$d. Update the plugin first.', 'bw-ai-schema-pro' ),
					$format_version,
					self::FORMAT_VERSION
				)
			);
		}

		$has_block = false;
		foreach ( self::get_blocks() as $block ) {
			if ( ! empty( $data[ $block ] ) && is_array( $data[ $block ] ) ) {
				$has_block = true;
				break;
			}
		}

		if ( ! $has_block ) {
			return new WP_Error( 'bw_schema_empty', __( 'That file contains no settings to import.', 'bw-ai-schema-pro' ) );
		}

		return $data;
	}

	/**
	 * A non-fatal warning about a payload, or '' when there's nothing to say.
	 *
	 * @param array $data Validated payload.
	 * @return string
	 */
	public static function get_warning( $data ) {
		$file_version = (string) ( $data['_meta']['plugin_version'] ?? '' );
		$running      = defined( 'BW_SCHEMA_VERSION' ) ? BW_SCHEMA_VERSION : '';

		if ( $file_version && $running && version_compare( $file_version, $running, '>' ) ) {
			return sprintf(
				/* translators: 1: file's plugin version, 2: running plugin version */
				__( 'This file came from plugin version %1$s but you are running %2$s. Settings added after %2$s will be ignored.', 'bw-ai-schema-pro' ),
				$file_version,
				$running
			);
		}

		return '';
	}

	/* ---------------------------------------------------------------------
	 * Sanitization
	 * ------------------------------------------------------------------ */

	/**
	 * Keys whose value is free-form multi-line text — sanitize_text_field would
	 * strip the line breaks.
	 *
	 * @return string[]
	 */
	private static function textarea_keys() {
		return array(
			'bw_schema_org_description',
			'bw_schema_publishing_principles',
			'bw_schema_fact_checking_policy',
		);
	}

	/**
	 * Array keys whose value should be treated as a URL wherever it appears.
	 *
	 * @return string[]
	 */
	private static function url_keys() {
		return array( 'url', 'logo', 'image', 'website', 'sameAs', 'thumbnail', 'layoutImage', 'booking_url', 'embed_url', 'content_url' );
	}

	/**
	 * Sanitize one option value for storage.
	 *
	 * Delegates to the modules' own sanitizers where they exist, so there is one
	 * definition of what a valid location or service area looks like.
	 *
	 * @param string $key   Option name.
	 * @param mixed  $value Raw value from the file.
	 * @return mixed Sanitized value.
	 */
	public static function sanitize_value( $key, $value ) {
		// Reuse the Locations module's own sanitizers — single source of truth.
		if ( 'bw_schema_locations' === $key && class_exists( 'BW_Schema_Location' ) ) {
			return BW_Schema_Location::sanitize_locations( is_array( $value ) ? $value : array() );
		}
		if ( 'bw_schema_service_areas' === $key && class_exists( 'BW_Schema_Location' ) ) {
			return BW_Schema_Location::sanitize_areas( is_array( $value ) ? $value : array() );
		}

		// The legacy override holds a raw JSON string; keep it only if it parses.
		if ( 'bw_schema_organization_override' === $key ) {
			if ( ! is_string( $value ) || '' === trim( $value ) ) {
				return '';
			}
			json_decode( $value, true );

			return ( JSON_ERROR_NONE === json_last_error() ) ? $value : '';
		}

		if ( in_array( $key, self::textarea_keys(), true ) ) {
			return is_scalar( $value ) ? sanitize_textarea_field( (string) $value ) : '';
		}

		return self::sanitize_recursive( $value );
	}

	/**
	 * Recursively sanitize a value by shape.
	 *
	 * @param mixed  $value    Value.
	 * @param string $key_hint Key this value sits under, used to spot URLs.
	 * @return mixed
	 */
	private static function sanitize_recursive( $value, $key_hint = '' ) {
		if ( is_array( $value ) ) {
			$out = array();
			foreach ( $value as $k => $v ) {
				$clean_key         = is_string( $k ) ? sanitize_text_field( $k ) : $k;
				$out[ $clean_key ] = self::sanitize_recursive( $v, is_string( $k ) ? $k : $key_hint );
			}

			return $out;
		}

		if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
			return $value;
		}

		if ( ! is_string( $value ) ) {
			return '';
		}

		if ( in_array( $key_hint, self::url_keys(), true ) || 0 === strpos( $value, 'http' ) ) {
			// esc_url_raw drops javascript: and other unsafe protocols.
			return esc_url_raw( $value );
		}

		return sanitize_text_field( $value );
	}

	/* ---------------------------------------------------------------------
	 * Import
	 * ------------------------------------------------------------------ */

	/**
	 * Apply a validated payload.
	 *
	 * Takes a snapshot first, so the change is always undoable.
	 *
	 * @param array  $data     Validated payload.
	 * @param array  $includes Blocks to apply.
	 * @param string $reason   Human label for the undo prompt.
	 * @return array|WP_Error  ['applied' => int, 'skipped' => int, 'keys' => []]
	 */
	public static function import( $data, $includes, $reason ) {
		$data = self::validate( $data );
		if ( is_wp_error( $data ) ) {
			return $data;
		}

		$includes = array_values( array_intersect( self::get_blocks(), (array) $includes ) );
		if ( empty( $includes ) ) {
			return new WP_Error( 'bw_schema_nothing_selected', __( 'Nothing was selected to import.', 'bw-ai-schema-pro' ) );
		}

		self::snapshot( $reason );

		$tier_map = self::get_tier_map();
		$applied  = array();
		$skipped  = 0;

		foreach ( $includes as $block ) {
			if ( empty( $data[ $block ] ) || ! is_array( $data[ $block ] ) ) {
				continue;
			}

			foreach ( $data[ $block ] as $key => $value ) {
				// Allowlist check — the security control that matters here. A key
				// that isn't ours, or that is in a different block than claimed,
				// is ignored rather than written.
				if ( ! isset( $tier_map[ $key ] ) || $tier_map[ $key ] !== $block ) {
					$skipped++;
					continue;
				}

				update_option( $key, self::sanitize_value( $key, $value ) );
				$applied[] = $key;
			}
		}

		self::reconcile_settings_mirror();
		self::flush_caches();

		return array(
			'applied' => count( $applied ),
			'skipped' => $skipped,
			'keys'    => $applied,
		);
	}

	/* ---------------------------------------------------------------------
	 * The sectioned-settings mirror
	 * ------------------------------------------------------------------ */

	/**
	 * Reconcile `bw_schema_settings` with the top-level options it duplicates.
	 *
	 * `BW_Schema_Core::get_settings()` reads the sectioned mirror FIRST and only
	 * falls back to the top-level option. So after an import or an undo, a stale
	 * mirror would keep serving the old team CPT / room CPT / page mappings even
	 * though the top-level options were replaced. This runs after every write.
	 *
	 * Convergence is deliberately two-way. Copying top-level → mirror
	 * unconditionally would wipe a mirror value on a site where only the mirror
	 * was ever populated (the mirror is the one `get_settings()` prefers, so that
	 * site would silently lose its team CPT). So: a non-empty top-level value
	 * wins; otherwise a non-empty mirror value is backfilled up to the top level;
	 * if both are empty there is nothing to do.
	 */
	public static function reconcile_settings_mirror() {
		$settings = get_option( 'bw_schema_settings', array() );
		if ( ! is_array( $settings ) ) {
			$settings = array();
		}

		if ( ! isset( $settings['content'] ) || ! is_array( $settings['content'] ) ) {
			$settings['content'] = array();
		}

		$pairs = array(
			'team_post_type'     => 'bw_schema_team_post_type',
			'room_post_type'     => 'bw_schema_room_post_type',
			'page_mappings'      => 'bw_schema_page_mappings',
			'post_type_defaults' => 'bw_schema_post_type_defaults',
		);

		foreach ( $pairs as $mirror_key => $option_key ) {
			$top    = get_option( $option_key, null );
			$mirror = $settings['content'][ $mirror_key ] ?? null;

			if ( ! empty( $top ) ) {
				$settings['content'][ $mirror_key ] = $top;
			} elseif ( ! empty( $mirror ) ) {
				update_option( $option_key, $mirror );
			}
		}

		update_option( 'bw_schema_settings', $settings );
	}

	/**
	 * Drop cached schema so the next page load rebuilds from the new config.
	 */
	private static function flush_caches() {
		if ( class_exists( 'BW_Schema_Cache' ) ) {
			BW_Schema_Cache::clear_all();
		}
	}

	/* ---------------------------------------------------------------------
	 * Snapshot / undo
	 * ------------------------------------------------------------------ */

	/**
	 * Save the current config into the single undo slot.
	 *
	 * Captures config + site_specific + authors, so an undo restores everything
	 * an import could possibly have touched.
	 *
	 * @param string $reason Human label, e.g. 'Import configuration file'.
	 */
	public static function snapshot( $reason ) {
		update_option(
			self::BACKUP_OPTION,
			array(
				'taken_at' => gmdate( 'c' ),
				'reason'   => sanitize_text_field( $reason ),
				'payload'  => self::export( self::get_blocks() ),
			)
		);
	}

	/**
	 * The stored snapshot, or null.
	 *
	 * @return array|null
	 */
	public static function get_backup() {
		$backup = get_option( self::BACKUP_OPTION, null );

		return ( is_array( $backup ) && ! empty( $backup['payload'] ) ) ? $backup : null;
	}

	/**
	 * Restore the snapshot and clear it.
	 *
	 * Deliberately does NOT snapshot again — undo is a one-step escape hatch, and
	 * snapshotting here would let a second undo re-apply what was just reverted.
	 *
	 * @return array|WP_Error
	 */
	public static function undo() {
		$backup = self::get_backup();

		if ( ! $backup ) {
			return new WP_Error( 'bw_schema_no_backup', __( 'There is no saved configuration to restore.', 'bw-ai-schema-pro' ) );
		}

		$payload  = $backup['payload'];
		$tier_map = self::get_tier_map();
		$restored = 0;

		foreach ( self::get_blocks() as $block ) {
			if ( empty( $payload[ $block ] ) || ! is_array( $payload[ $block ] ) ) {
				continue;
			}
			foreach ( $payload[ $block ] as $key => $value ) {
				if ( ! isset( $tier_map[ $key ] ) ) {
					continue;
				}
				update_option( $key, $value );
				$restored++;
			}
		}

		// Keys that did not exist when the snapshot was taken but do now were
		// created by the change being undone — remove them, or an undo would
		// leave debris behind.
		foreach ( $tier_map as $key => $block ) {
			$in_snapshot = isset( $payload[ $block ][ $key ] );
			if ( ! $in_snapshot && false !== get_option( $key, false ) ) {
				delete_option( $key );
			}
		}

		self::reconcile_settings_mirror();
		self::flush_caches();
		delete_option( self::BACKUP_OPTION );

		return array( 'restored' => $restored, 'reason' => $backup['reason'] );
	}
}
