<?php

namespace CelerSearch\Utilities;

defined( 'ABSPATH' ) || exit;

use CelerSearch\DataTransfer\IndexConfig;
use CelerSearch\DataTransfer\ServiceConfig;
use CelerSearch\Repositories\IndexRepository;
use CelerSearch\Repositories\ServiceRepository;

/**
 * Builds and consumes the portable JSON envelope that the import/export
 * feature uses for a single Index, Service or Settings record. Centralizes
 * envelope construction, secret redaction, and FK rewriting (id <-> slug)
 * so the Ajax handlers stay thin.
 */
class PortableConfig {

	const FORMAT          = 'celersearch-export';
	const SCHEMA_VERSION  = 1;
	const REDACTED_MARKER = '__REDACTED__';

	const TYPE_INDEX    = 'index';
	const TYPE_SERVICE  = 'service';
	const TYPE_SETTINGS = 'settings';

	/**
	 * Build the standard envelope around a payload.
	 *
	 * @param string $type     One of TYPE_INDEX, TYPE_SERVICE, TYPE_SETTINGS.
	 * @param array  $data     Payload.
	 * @param bool   $secrets  Whether secrets are included in the payload.
	 *
	 * @return array
	 */
	private static function envelope( string $type, array $data, bool $secrets ): array {
		return [
			'format'           => self::FORMAT,
			'schema_version'   => self::SCHEMA_VERSION,
			'type'             => $type,
			'exported_at'      => gmdate( 'c' ),
			'plugin_version'   => defined( 'CELERSEARCH_PLUGIN_VERSION' ) ? CELERSEARCH_PLUGIN_VERSION : '',
			'secrets_included' => $secrets,
			'data'             => $data,
		];
	}

	/**
	 * Validate that an array is a CelerSearch export envelope of the expected
	 * type (or any valid type if $expected_type is null).
	 *
	 * @throws \InvalidArgumentException When the envelope is malformed.
	 */
	public static function validate_envelope( $envelope, ?string $expected_type = null ): void {
		if ( ! is_array( $envelope ) ) {
			throw new \InvalidArgumentException( __( 'Import file is not a valid JSON object.', 'celersearch' ) );
		}
		if ( ( $envelope['format'] ?? '' ) !== self::FORMAT ) {
			throw new \InvalidArgumentException( __( 'Import file is not a CelerSearch export.', 'celersearch' ) );
		}
		$type = $envelope['type'] ?? '';
		if ( ! in_array( $type, [ self::TYPE_INDEX, self::TYPE_SERVICE, self::TYPE_SETTINGS ], true ) ) {
			throw new \InvalidArgumentException( __( 'Unknown export type.', 'celersearch' ) );
		}
		if ( $expected_type !== null && $type !== $expected_type ) {
			throw new \InvalidArgumentException(
				/* translators: %1$s expected type, %2$s actual type */
				sprintf( __( 'Expected a %1$s export but the file contains a %2$s.', 'celersearch' ), $expected_type, $type )
			);
		}
		if ( ! isset( $envelope['data'] ) || ! is_array( $envelope['data'] ) ) {
			throw new \InvalidArgumentException( __( 'Import file is missing its data payload.', 'celersearch' ) );
		}
	}

	// ---------------------------------------------------------------------
	// Index
	// ---------------------------------------------------------------------

	/**
	 * Export an Index record to a portable envelope. The referenced Service
	 * is included by `name` + `provider` so the importer can try to relink
	 * by Service identity rather than by raw FK id (ids don't survive a
	 * cross-site move).
	 */
	public static function export_index( IndexConfig $index ): array {
		$service_ref = null;
		if ( $index->getServiceId() ) {
			$service_repo = new ServiceRepository();
			$service      = $service_repo->find( (int) $index->getServiceId() );
			if ( $service ) {
				$service_ref = [
					'name'     => (string) $service->getName(),
					'provider' => (string) $service->getProvider(),
				];
			}
		}

		$config = $index->getConfig();
		// Convert object/stdClass back to associative array for stable JSON shape.
		if ( is_object( $config ) ) {
			$config = json_decode( wp_json_encode( $config ), true );
		}

		$data = [
			'name'        => (string) $index->getName(),
			'type'        => (string) $index->getType(),
			'slug'        => (string) $index->getSlug(),
			'config'      => is_array( $config ) ? $config : [],
			'service_ref' => $service_ref,
		];

		// Indices never embed secrets, but we still set the flag for symmetry.
		return self::envelope( self::TYPE_INDEX, $data, false );
	}

	/**
	 * Import an Index from an envelope. Always creates a new record — slug
	 * collisions are resolved by appending `-1`, `-2`, … to mirror the
	 * existing create-flow dedup behavior in Ajax::indices_store().
	 *
	 * @return array{record: IndexConfig, service_ref_resolved: bool}
	 */
	public static function import_index( array $envelope ): array {
		self::validate_envelope( $envelope, self::TYPE_INDEX );

		$data       = $envelope['data'];
		$name       = isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '';
		$type       = isset( $data['type'] ) ? sanitize_text_field( (string) $data['type'] ) : '';
		$slug_seed  = isset( $data['slug'] ) ? sanitize_title( (string) $data['slug'] ) : sanitize_title( $name );
		$config_in  = isset( $data['config'] ) && is_array( $data['config'] ) ? $data['config'] : [];

		if ( $name === '' || $type === '' ) {
			throw new \InvalidArgumentException( __( 'Index export is missing required fields.', 'celersearch' ) );
		}

		$service_id           = null;
		$service_ref_resolved = false;
		if ( ! empty( $data['service_ref'] ) && is_array( $data['service_ref'] ) ) {
			$ref      = $data['service_ref'];
			$ref_name = isset( $ref['name'] ) ? (string) $ref['name'] : '';
			$ref_prov = isset( $ref['provider'] ) ? (string) $ref['provider'] : '';
			$match    = self::find_service_by_name_provider( $ref_name, $ref_prov );
			if ( $match ) {
				$service_id           = (int) $match->getId();
				$service_ref_resolved = true;
			}
		}

		// Sanitize config recursively (text fields). Mirrors Ajax::indices_store.
		$config = map_deep( $config_in, 'sanitize_text_field' );

		// Slug dedup — replicate the loop from Ajax::indices_store().
		global $wpdb;
		$table         = $wpdb->prefix . 'celersearch_indices';
		$slug          = $slug_seed !== '' ? $slug_seed : sanitize_title( $name );
		$original_slug = $slug;
		$counter       = 1;
		while ( true ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table
			$existing = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE slug = %s", $slug ) );
			if ( $existing === 0 ) {
				break;
			}
			$slug = $original_slug . '-' . $counter;
			$counter++;
		}

		$repo   = new IndexRepository();
		$record = $repo->create( [
			'name'       => $name,
			'type'       => $type,
			'slug'       => $slug,
			'service_id' => $service_id,
			'config'     => $config,
			'status'     => 'pending',
		] );

		if ( ! $record ) {
			throw new \RuntimeException( __( 'Unable to create the imported index.', 'celersearch' ) );
		}

		return [
			'record'               => $record,
			'service_ref_resolved' => $service_ref_resolved,
		];
	}

	// ---------------------------------------------------------------------
	// Service
	// ---------------------------------------------------------------------

	/**
	 * Export a Service record. Secrets in `config` are redacted unless
	 * $include_secrets is true. Redacted means: the key remains in the
	 * payload but the value is replaced with the REDACTED_MARKER constant
	 * so that re-importing without re-entering credentials does not silently
	 * create a Service with a blank key (the importer skips redacted keys).
	 */
	public static function export_service( ServiceConfig $service, bool $include_secrets ): array {
		$config = $service->getConfig();
		if ( is_object( $config ) ) {
			$config = json_decode( wp_json_encode( $config ), true );
		}
		if ( ! is_array( $config ) ) {
			$config = [];
		}

		if ( ! $include_secrets ) {
			$config = self::redact_secret_fields( $config );
		}

		$data = [
			'name'     => (string) $service->getName(),
			'provider' => (string) $service->getProvider(),
			'config'   => $config,
		];

		return self::envelope( self::TYPE_SERVICE, $data, $include_secrets );
	}

	/**
	 * Import a Service from an envelope. Always creates a new record — name
	 * collisions are resolved by suffixing " (1)", " (2)", … (Services have
	 * no slug column, so we dedupe on the human name).
	 *
	 * Status is reset to PENDING and failure_count to 0 — the user is
	 * expected to test the connection (or it will be tested on first use).
	 */
	public static function import_service( array $envelope ): ServiceConfig {
		self::validate_envelope( $envelope, self::TYPE_SERVICE );

		$data     = $envelope['data'];
		$name     = isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '';
		$provider = isset( $data['provider'] ) ? sanitize_text_field( (string) $data['provider'] ) : '';
		$config   = isset( $data['config'] ) && is_array( $data['config'] ) ? $data['config'] : [];

		if ( $name === '' || $provider === '' ) {
			throw new \InvalidArgumentException( __( 'Service export is missing required fields.', 'celersearch' ) );
		}

		$config = map_deep( $config, 'sanitize_text_field' );
		$config = self::strip_redacted_fields( $config );

		$name = self::dedupe_service_name( $name );

		$repo   = new ServiceRepository();
		$record = $repo->create( [
			'name'          => $name,
			'provider'      => $provider,
			'config'        => $config,
			'status'        => ServiceRepository::STATUS_PENDING,
			'failure_count' => 0,
		] );

		if ( ! $record ) {
			throw new \RuntimeException( __( 'Unable to create the imported service.', 'celersearch' ) );
		}

		return $record;
	}

	// ---------------------------------------------------------------------
	// Settings
	// ---------------------------------------------------------------------

	/**
	 * Export the celersearch_settings option, rewriting index FKs to slugs
	 * so they survive a cross-site move.
	 */
	public static function export_settings(): array {
		$settings = get_option( 'celersearch_settings', [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		$index_repo  = new IndexRepository();
		$id_to_slug  = function ( $id ) use ( $index_repo ) {
			$id = (int) $id;
			if ( $id <= 0 ) {
				return null;
			}
			$cfg = $index_repo->find( $id );
			return $cfg ? (string) $cfg->getSlug() : null;
		};

		$payload = [
			'enable_search'      => ! empty( $settings['enable_search'] ),
			'default_index_slug' => $id_to_slug( $settings['default_index_id'] ?? 0 ),
			'fallback_to_native' => isset( $settings['fallback_to_native'] ) ? (bool) $settings['fallback_to_native'] : true,
			'batch_size'         => isset( $settings['batch_size'] ) ? (int) $settings['batch_size'] : 100,
			'search_areas'       => [],
		];

		$areas = isset( $settings['search_areas'] ) && is_array( $settings['search_areas'] ) ? $settings['search_areas'] : [];
		foreach ( $areas as $area ) {
			if ( ! is_array( $area ) ) {
				continue;
			}
			$exported = [
				'id'           => $area['id'] ?? '',
				'type'         => $area['type'] ?? '',
				'label'        => $area['label'] ?? '',
				'index_slug'   => $id_to_slug( $area['index_id'] ?? 0 ),
				'default_sort' => $area['default_sort'] ?? '',
				'features'     => isset( $area['features'] ) && is_array( $area['features'] ) ? $area['features'] : [],
				'enabled'      => ! empty( $area['enabled'] ),
			];
			if ( ! empty( $area['post_type'] ) ) {
				$exported['post_type'] = $area['post_type'];
			}
			$payload['search_areas'][] = $exported;
		}

		return self::envelope( self::TYPE_SETTINGS, $payload, false );
	}

	/**
	 * Import settings, rewriting index slugs back to ids. Unresolved slugs
	 * (the referenced index does not exist on this site) become null and
	 * the affected search area is force-disabled. The list of unresolved
	 * references is returned alongside the saved settings so the UI can
	 * surface a warning.
	 *
	 * @return array{settings: array, unresolved: array<int, array{scope:string, label:string, slug:string}>}
	 */
	public static function import_settings( array $envelope ): array {
		self::validate_envelope( $envelope, self::TYPE_SETTINGS );

		$data       = $envelope['data'];
		$index_repo = new IndexRepository();

		$slug_to_id = function ( $slug ) use ( $index_repo ) {
			if ( ! is_string( $slug ) || $slug === '' ) {
				return 0;
			}
			$cfg = $index_repo->find_by_slug( $slug );
			return $cfg ? (int) $cfg->getId() : 0;
		};

		$unresolved = [];

		// Default index.
		$default_slug = isset( $data['default_index_slug'] ) && is_string( $data['default_index_slug'] ) ? $data['default_index_slug'] : '';
		$default_id   = $slug_to_id( $default_slug );
		if ( $default_slug !== '' && $default_id === 0 ) {
			$unresolved[] = [
				'scope' => 'default',
				'label' => __( 'Default index', 'celersearch' ),
				'slug'  => $default_slug,
			];
		}

		// Search areas.
		$areas_in        = isset( $data['search_areas'] ) && is_array( $data['search_areas'] ) ? $data['search_areas'] : [];
		$sanitized_areas = [];
		foreach ( $areas_in as $area ) {
			if ( ! is_array( $area ) ) {
				continue;
			}

			$type = isset( $area['type'] ) ? sanitize_text_field( (string) $area['type'] ) : '';
			if ( $type === '' ) {
				continue;
			}

			$slug         = isset( $area['index_slug'] ) ? (string) $area['index_slug'] : '';
			$resolved_id  = $slug_to_id( $slug );
			$enabled      = ! empty( $area['enabled'] );
			$area_label   = isset( $area['label'] ) ? sanitize_text_field( (string) $area['label'] ) : '';

			if ( $slug !== '' && $resolved_id === 0 ) {
				$unresolved[] = [
					'scope' => 'area',
					'label' => $area_label !== '' ? $area_label : $type,
					'slug'  => $slug,
				];
				// Force-disable areas pointing at a missing index — leaving them
				// "enabled" with index_id=0 would silently break search in that area.
				$enabled = false;
			}

			$sanitized = [
				'id'           => isset( $area['id'] ) ? sanitize_title( (string) $area['id'] ) : uniqid( 'area_' ),
				'type'         => $type,
				'label'        => $area_label,
				'index_id'     => $resolved_id,
				'default_sort' => isset( $area['default_sort'] ) ? sanitize_text_field( (string) $area['default_sort'] ) : '',
				'features'     => isset( $area['features'] ) && is_array( $area['features'] ) ? $area['features'] : [],
				'enabled'      => $enabled,
			];

			if ( ! empty( $area['post_type'] ) ) {
				$sanitized['post_type'] = sanitize_text_field( (string) $area['post_type'] );
			}

			$sanitized_areas[] = $sanitized;
		}

		$settings = [
			'enable_search'      => ! empty( $data['enable_search'] ),
			'default_index_id'   => $default_id,
			'search_areas'       => $sanitized_areas,
			'fallback_to_native' => isset( $data['fallback_to_native'] ) ? (bool) $data['fallback_to_native'] : true,
			'batch_size'         => isset( $data['batch_size'] ) ? max( 10, min( 500, (int) $data['batch_size'] ) ) : 100,
		];

		update_option( 'celersearch_settings', $settings );

		return [
			'settings'   => $settings,
			'unresolved' => $unresolved,
		];
	}

	// ---------------------------------------------------------------------
	// Helpers
	// ---------------------------------------------------------------------

	/**
	 * Replace any value whose key looks like a credential with REDACTED_MARKER.
	 * Recursive — covers nested config shapes.
	 */
	private static function redact_secret_fields( array $config ): array {
		foreach ( $config as $key => $value ) {
			if ( is_array( $value ) ) {
				$config[ $key ] = self::redact_secret_fields( $value );
				continue;
			}
			if ( self::is_secret_key( (string) $key ) && $value !== '' && $value !== null ) {
				$config[ $key ] = self::REDACTED_MARKER;
			}
		}
		return $config;
	}

	/**
	 * Drop any field whose value equals the REDACTED_MARKER. After this pass
	 * the imported Service simply has empty credentials, forcing the user
	 * to edit it before use.
	 */
	private static function strip_redacted_fields( array $config ): array {
		foreach ( $config as $key => $value ) {
			if ( is_array( $value ) ) {
				$config[ $key ] = self::strip_redacted_fields( $value );
				continue;
			}
			if ( $value === self::REDACTED_MARKER ) {
				unset( $config[ $key ] );
			}
		}
		return $config;
	}

	private static function is_secret_key( string $key ): bool {
		return (bool) preg_match( '/(key|secret|token|password|pass)/i', $key );
	}

	/**
	 * Find a Service by exact name + provider. Used by index-import to
	 * relink by identity.
	 */
	private static function find_service_by_name_provider( string $name, string $provider ): ?ServiceConfig {
		if ( $name === '' || $provider === '' ) {
			return null;
		}
		$repo = new ServiceRepository();
		try {
			$matches = $repo->get( [ 'name' => $name, 'provider' => $provider ], 1, 1 );
		} catch ( \Exception $e ) {
			return null;
		}
		return $matches ? $matches[0] : null;
	}

	/**
	 * Append " (1)", " (2)", … to a Service name until it doesn't collide
	 * with an existing row. Services have no slug column so name is the
	 * only human-visible identifier we can dedupe on.
	 */
	private static function dedupe_service_name( string $name ): string {
		global $wpdb;
		$table = $wpdb->prefix . 'celersearch_services';

		$candidate = $name;
		$counter   = 1;
		while ( true ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table
			$exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE name = %s", $candidate ) );
			if ( $exists === 0 ) {
				return $candidate;
			}
			$candidate = $name . ' (' . $counter . ')';
			$counter++;
		}
	}
}
