<?php
/**
 * Moving a configuration between sites.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Admin;

use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Exports and imports the settings as JSON (spec §20).
 *
 * For agencies who configure the same seven events, the same wording and the
 * same consent rules on every store they build, and for anyone moving a
 * carefully tuned staging setup to production without doing it twice.
 *
 * ### What deliberately does not travel
 *
 * **Credentials.** Not by filtering, but because they are not settings: the
 * token and signing secret are separate, encrypted options, so there is no path
 * for them to reach a file that gets emailed around or committed to a repo.
 *
 * **Sandbox mode.** This is the dangerous one, and the reason it is excluded is
 * worth stating plainly. Sandbox is a property of the *environment*, not of the
 * configuration. Carrying it from a production export into a staging site turns
 * sandbox **off** there — and the staging clone, which still holds a copy of the
 * real customer database, starts texting real people. Carrying it the other way
 * silently stops a live store messaging anyone. The plugin already derives
 * sandbox from `wp_get_environment_type()`; letting a file override that would
 * defeat the one protection that needs no configuration.
 *
 * **The test phone number.** Somebody's personal mobile, and of no use to
 * whoever opens the file next.
 */
final class Portable {

	/**
	 * Format version, so a future change can be detected rather than guessed at.
	 */
	public const FORMAT = 1;

	/**
	 * Settings that must never travel between sites.
	 *
	 * @var string[]
	 */
	public const NEVER_EXPORT = array(
		'sandbox_mode',
		'sandbox_behavior',
		'sandbox_test_number',
		'test_phone',
	);

	/**
	 * The settings that may be carried, as they currently stand.
	 *
	 * @return array<string, mixed>
	 */
	public static function exportable(): array {
		return array_diff_key(
			array_intersect_key( Settings::all(), Settings::defaults() ),
			array_flip( self::NEVER_EXPORT )
		);
	}

	/**
	 * The whole file, ready to serialise.
	 *
	 * @return array<string, mixed>
	 */
	public static function export(): array {
		return array(
			'format'   => self::FORMAT,
			'plugin'   => TEXTTANGO_VERSION,
			'site'     => home_url( '/' ),
			'exported' => gmdate( 'c' ),
			'settings' => self::exportable(),
		);
	}

	/**
	 * A filename that says where and when it came from.
	 */
	public static function filename(): string {
		$host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST );

		return sprintf( 'texttango-settings-%s-%s.json', sanitize_file_name( $host ), gmdate( 'Y-m-d' ) );
	}

	/**
	 * Read a pasted file into settings this site will accept.
	 *
	 * Pure, so every way a paste can be wrong — truncated, from another plugin,
	 * hand-edited, from a much later version — is answerable without touching
	 * the database.
	 *
	 * @param string $json Raw JSON.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function parse( string $json ) {
		$decoded = json_decode( trim( $json ), true );

		if ( ! is_array( $decoded ) ) {
			return new WP_Error(
				'texttango_import_unreadable',
				__( 'That does not look like JSON. Paste the whole file, including the outermost braces.', 'texttango' )
			);
		}

		if ( ! isset( $decoded['settings'] ) || ! is_array( $decoded['settings'] ) ) {
			return new WP_Error(
				'texttango_import_wrong_shape',
				__( 'That JSON has no TextTango settings in it. It may be an export from a different plugin.', 'texttango' )
			);
		}

		if ( isset( $decoded['format'] ) && (int) $decoded['format'] > self::FORMAT ) {
			return new WP_Error(
				'texttango_import_too_new',
				__( 'That file was written by a newer version of this plugin than the one installed here. Update first, so nothing is quietly dropped.', 'texttango' )
			);
		}

		$candidates = array_diff_key(
			array_intersect_key( $decoded['settings'], Settings::defaults() ),
			array_flip( self::NEVER_EXPORT )
		);

		$defaults = Settings::defaults();
		$accepted = array();

		foreach ( $candidates as $key => $value ) {
			$accepted[ $key ] = self::coerce( $value, $defaults[ $key ] );
		}

		if ( empty( $accepted ) ) {
			return new WP_Error(
				'texttango_import_nothing_usable',
				__( 'Nothing in that file is a setting this version recognises, so nothing has been changed.', 'texttango' )
			);
		}

		return $accepted;
	}

	/**
	 * Force an imported value into the shape its setting expects.
	 *
	 * The type of the default is the specification. A file is untrusted input —
	 * hand-edited, machine-generated, or written by a version that meant
	 * something else by the same key — and a string arriving where the rest of
	 * the plugin expects an array is a fatal error somewhere far from here.
	 *
	 * Sanitising happens per value rather than over the whole paste, because
	 * running a text sanitiser across raw JSON mangles legitimate template
	 * content while doing nothing about the structural problem.
	 *
	 * @param mixed $value   Value from the file.
	 * @param mixed $expected Default for that setting, whose type governs.
	 * @return mixed
	 */
	private static function coerce( $value, $expected ) {
		if ( is_bool( $expected ) ) {
			return (bool) $value;
		}

		if ( is_int( $expected ) ) {
			return (int) $value;
		}

		if ( is_float( $expected ) ) {
			return (float) $value;
		}

		if ( is_array( $expected ) ) {
			return is_array( $value ) ? self::clean_array( $value ) : $expected;
		}

		return sanitize_textarea_field( (string) ( is_scalar( $value ) ? $value : '' ) );
	}

	/**
	 * Clean the leaves of a nested settings value.
	 *
	 * @param array<mixed> $value Value from the file.
	 * @return array<mixed>
	 */
	private static function clean_array( array $value ): array {
		$clean = array();

		foreach ( $value as $key => $item ) {
			$key = is_string( $key ) ? sanitize_key( $key ) : $key;

			if ( is_array( $item ) ) {
				$clean[ $key ] = self::clean_array( $item );

				continue;
			}

			if ( is_bool( $item ) || is_int( $item ) ) {
				$clean[ $key ] = $item;

				continue;
			}

			$clean[ $key ] = sanitize_textarea_field( (string) ( is_scalar( $item ) ? $item : '' ) );
		}

		return $clean;
	}

	/**
	 * What an import would change, without changing it.
	 *
	 * Importing can switch on every order notification at once, so a merchant is
	 * shown what is about to happen rather than finding out from their customers.
	 *
	 * @param array<string, mixed> $incoming Settings from a parsed file.
	 * @return string[] Keys whose value would differ.
	 */
	public static function diff( array $incoming ): array {
		$current = Settings::all();
		$changed = array();

		foreach ( $incoming as $key => $value ) {
			if ( ! array_key_exists( $key, $current ) || $current[ $key ] !== $value ) {
				$changed[] = $key;
			}
		}

		return $changed;
	}

	/**
	 * Apply a parsed import.
	 *
	 * Goes through {@see Settings::update()} like any other change, so the audit
	 * trail records it and no key outside the known set can be written.
	 *
	 * @param array<string, mixed> $incoming Settings from a parsed file.
	 * @return int How many settings changed.
	 */
	public static function apply( array $incoming ): int {
		$changed = self::diff( $incoming );

		if ( empty( $changed ) ) {
			return 0;
		}

		Settings::update( $incoming );

		return count( $changed );
	}
}
