<?php
/**
 * Geo Database Manager
 *
 * Handles downloading, storing, and updating the DB-IP Lite City MMDB database.
 *
 * @link       https://personalizewp.com/
 * @since      3.5.0
 *
 * @package    PersonalizeWP
 */

namespace PersonalizeWP;

use PersonalizeWP\Traits\SingletonTrait;

defined( 'ABSPATH' ) || exit; // Exit if accessed directly.

/**
 * Geo Database Manager class.
 *
 * Downloads and maintains the DB-IP Lite City MMDB for geo lookups.
 */
class Geo_Database_Manager {

	use SingletonTrait;

	/**
	 * Database filename.
	 */
	const DB_FILENAME = 'dbip-city-lite.mmdb';

	/**
	 * WP-Cron hook name for monthly updates.
	 */
	const CRON_HOOK = 'personalizewp_update_geo_database';

	/**
	 * Option name for tracking database status.
	 */
	const STATUS_OPTION = 'pwp_geo_database_status';

	/**
	 * Maximum allowed decompressed MMDB size in bytes.
	 */
	const MAX_DECOMPRESSED_BYTES = 524288000; // 500 MB.

	/**
	 * Minimum plausible MMDB size in bytes.
	 */
	const MIN_VALID_DATABASE_BYTES = 1048576; // 1 MB.

	/**
	 * Seconds before a download lock is considered stale.
	 */
	const DOWNLOAD_STALE_TTL = 600; // 10 minutes.

	/**
	 * Set up hooks and check database availability.
	 *
	 * Registers cron callback, admin notices, and triggers a background
	 * download if the database file is missing (handles plugin updates).
	 *
	 * @since 3.5.0
	 */
	public function setup() {
		add_action( self::CRON_HOOK, [ $this, 'download_database' ] );

		if ( is_admin() ) {
			add_action( 'admin_notices', [ $this, 'admin_notice' ] );
			add_action( 'wp_ajax_pwp_retry_geo_download', [ $this, 'handle_retry_download' ] );
		}

		// Move the database from its legacy uploads location if needed (3.5.3+).
		$this->maybe_migrate_legacy_database();

		// Auto-check: trigger download if database is missing (handles plugin updates).
		$this->maybe_download_database();
	}

	/**
	 * Check if the database is missing and schedule a background download.
	 *
	 * This ensures users who update the plugin (not just activate) will
	 * automatically get the geo database downloaded.
	 *
	 * @since 3.5.0
	 */
	private function maybe_download_database() {
		if ( $this->is_database_available() ) {
			return;
		}

		$status = $this->get_database_status();
		if ( 'downloading' === $status['status'] ) {
			if ( ! $this->is_download_stale( $status ) ) {
				return;
			}

			$this->cleanup_temp_files( ...$this->get_temp_file_paths() );
		}

		$this->schedule_download();
	}

	/**
	 * Get the full path to the MMDB database file.
	 *
	 * Stored under wp-content/personalizewp/ rather than wp-content/uploads/
	 * so media-only migrations and backups (WP Migrate, UpdraftPlus, etc.)
	 * skip it by default — the file is large (100MB+) and per-environment,
	 * and shipping it across hosts often trips upload-size firewall rules.
	 * Deliberately kept outside wp-content/cache/ because page-cache plugins
	 * (W3 Total Cache, WP Super Cache, LiteSpeed, etc.) treat that directory
	 * as their own and may wipe it on "purge everything" operations.
	 *
	 * On multisite, subsites get their own per-site subdirectory so a
	 * deactivation/uninstall on one site cannot remove the database used
	 * by another. The structure mirrors wp_upload_dir(): main site lives
	 * at the root, subsites under sites/{blog_id}/.
	 *
	 * @since 3.5.0
	 * @since 3.5.3 Default location moved out of uploads; filter introduced.
	 *
	 * @return string Full filesystem path.
	 */
	public function get_database_path() {
		$dir = WP_CONTENT_DIR . '/personalizewp';
		if ( is_multisite() && ! is_main_site() ) {
			$dir .= '/sites/' . get_current_blog_id();
		}
		$path = trailingslashit( $dir ) . self::DB_FILENAME;

		/**
		 * Filter the full filesystem path to the geo database file.
		 *
		 * @since 3.5.3
		 *
		 * @param string $path Default path (under wp-content/personalizewp/).
		 */
		return apply_filters( 'personalizewp_geo_database_path', $path );
	}

	/**
	 * Get the legacy (pre-3.5.3) MMDB path under wp-content/uploads/.
	 *
	 * @since 3.5.3
	 *
	 * @return string Full filesystem path to the legacy file location.
	 */
	private function get_legacy_database_path() {
		$upload_dir = wp_upload_dir();
		return trailingslashit( $upload_dir['basedir'] ) . 'personalizewp/' . self::DB_FILENAME;
	}

	/**
	 * Move the database from its legacy uploads location to the new path.
	 *
	 * Idempotent. Returns silently if:
	 *  - the legacy file is missing,
	 *  - or the filter has redirected the path back to the legacy location.
	 *
	 * If the new location is already populated but a legacy file still
	 * exists for this site (e.g. partial migration, restored backup),
	 * the legacy file and its directory are still cleaned up so the 100MB+
	 * payload doesn't linger in uploads/ defeating the point of the move.
	 *
	 * @since 3.5.3
	 */
	private function maybe_migrate_legacy_database() {
		$legacy  = $this->get_legacy_database_path();
		$current = $this->get_database_path();

		if ( $legacy === $current ) {
			return;
		}
		if ( ! file_exists( $legacy ) ) {
			return;
		}

		// New location already populated — just clean up the orphaned legacy
		// file so it doesn't keep getting picked up by media-only migrations.
		if ( file_exists( $current ) ) {
			$this->cleanup_legacy_directory( $legacy );
			return;
		}

		$current_dir = dirname( $current );
		if ( ! wp_mkdir_p( $current_dir ) ) {
			return;
		}

		// Prefer rename() (atomic on a single filesystem) but fall back to
		// copy()+unlink() when source and destination are on different mounts
		// — common on managed hosts where uploads/ lives on NFS/S3-fuse.
		// Without the fallback, rename() returns false silently and the
		// migration retries the same failing operation on every request.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
		$moved = @rename( $legacy, $current );
		if ( ! $moved ) {
			if ( ! @copy( $legacy, $current ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy
				return;
			}
		}

		$this->ensure_directory_protection( $current_dir );
		$this->cleanup_legacy_directory( $legacy );
	}

	/**
	 * Remove the legacy MMDB file, its temp leftovers, guard files, and dir.
	 *
	 * Called after a successful migration and also when the new location is
	 * already populated for this site but a stale legacy file remains.
	 * Cleans up `.download` / `.tmp` files that an interrupted pre-3.5.3
	 * download would have left behind — without this, @rmdir silently fails
	 * because the directory still contains them, leaving the legacy folder
	 * in place with potentially hundreds of MB of orphaned partial downloads.
	 *
	 * All operations are best-effort: if a file can't be removed we leave
	 * the directory rather than risk deleting anything we didn't put there.
	 *
	 * @since 3.5.3
	 *
	 * @param string $legacy_file Legacy MMDB path (used to derive the directory).
	 */
	private function cleanup_legacy_directory( $legacy_file ) {
		$legacy_dir = dirname( $legacy_file );

		@unlink( $legacy_file );                                 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( $legacy_file . '.download' );                   // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( $legacy_file . '.tmp' );                        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( trailingslashit( $legacy_dir ) . '.htaccess' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@unlink( trailingslashit( $legacy_dir ) . 'index.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
		@rmdir( $legacy_dir );                                   // phpcs:ignore WordPress.WP.AlternativeFunctions.rmdir_rmdir
	}

	/**
	 * Drop .htaccess + index.php into the database directory to block direct access.
	 *
	 * @since 3.5.3
	 *
	 * @param string $dir Directory to protect.
	 */
	private function ensure_directory_protection( $dir ) {
		$htaccess = trailingslashit( $dir ) . '.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			file_put_contents( $htaccess, "deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}
		$index = trailingslashit( $dir ) . 'index.php';
		if ( ! file_exists( $index ) ) {
			file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}
	}

	/**
	 * Check if the MMDB database file exists and is readable.
	 *
	 * @since 3.5.0
	 *
	 * @return bool
	 */
	public function is_database_available() {
		$path = $this->get_database_path();
		return file_exists( $path ) && is_readable( $path );
	}

	/**
	 * Download the latest DB-IP Lite City MMDB database.
	 *
	 * Downloads the gzipped file from DB-IP's public URL, decompresses it,
	 * and saves it to the configured database path (see get_database_path()).
	 *
	 * @since 3.5.0
	 *
	 * @return bool True on success, false on failure.
	 */
	public function download_database() {

		// Check for zlib support.
		if ( ! function_exists( 'gzopen' ) ) {
			$this->update_status( 'error', __( 'PHP zlib extension is required for geolocation database.', 'personalizewp' ) );
			return false;
		}

		// Ensure the output directory exists.
		$dir = dirname( $this->get_database_path() );
		if ( ! wp_mkdir_p( $dir ) ) {
			$this->update_status( 'error', __( 'Failed to create database directory.', 'personalizewp' ) );
			return false;
		}

		// Prevent directory listing and direct access.
		$this->ensure_directory_protection( $dir );

		$url = $this->get_download_url();
		list( $temp_dl, $temp_out ) = $this->get_temp_file_paths();

		// Clear leftovers from any earlier failed or stale attempt before starting again.
		$this->cleanup_temp_files( $temp_dl, $temp_out );

		// Stream the download directly to a temp file to avoid holding it in memory.
		// Disable decompress so WordPress doesn't auto-inflate the gzip file.
		$response = wp_remote_get(
			$url,
			[
				'timeout'    => 300,
				'stream'     => true,
				'filename'   => $temp_dl,
				'decompress' => false,
			]
		);

		if ( is_wp_error( $response ) ) {
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', $response->get_error_message() );
			return false;
		}

		$response_code = wp_remote_retrieve_response_code( $response );
		if ( 200 !== $response_code ) {
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', sprintf(
				/* translators: %d: HTTP response code */
				__( 'Database download failed with HTTP status %d.', 'personalizewp' ),
				$response_code
			) );
			return false;
		}

		// Decompress in chunks using zlib streams to stay within memory limits.
		$gz_handle = gzopen( $temp_dl, 'rb' );
		if ( ! $gz_handle ) {
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', __( 'Failed to open compressed database file.', 'personalizewp' ) );
			return false;
		}

		$out_handle = fopen( $temp_out, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( ! $out_handle ) {
			gzclose( $gz_handle );
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', __( 'Failed to write database file.', 'personalizewp' ) );
			return false;
		}

		$bytes_written = 0;

		while ( ! gzeof( $gz_handle ) ) {
			$chunk = gzread( $gz_handle, 524288 ); // 512 KB chunks.
			if ( false === $chunk ) {
				gzclose( $gz_handle );
				fclose( $out_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				$this->cleanup_temp_files( $temp_dl, $temp_out );
				$this->update_status( 'error', __( 'Failed while decompressing the geolocation database.', 'personalizewp' ) );
				return false;
			}

			$written = fwrite( $out_handle, $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
			if ( false === $written || strlen( $chunk ) !== $written ) {
				gzclose( $gz_handle );
				fclose( $out_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				$this->cleanup_temp_files( $temp_dl, $temp_out );
				$this->update_status( 'error', __( 'Failed to write the geolocation database to disk.', 'personalizewp' ) );
				return false;
			}

			$bytes_written += $written;
			if ( $bytes_written > self::MAX_DECOMPRESSED_BYTES ) {
				gzclose( $gz_handle );
				fclose( $out_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				$this->cleanup_temp_files( $temp_dl, $temp_out );
				$this->update_status(
					'error',
					sprintf(
						/* translators: %s: maximum allowed database size in MB */
						__( 'The decompressed geolocation database exceeded the %s MB safety limit.', 'personalizewp' ),
						number_format_i18n( self::MAX_DECOMPRESSED_BYTES / MB_IN_BYTES )
					)
				);
				return false;
			}
		}

		gzclose( $gz_handle );
		fclose( $out_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose

		$validation_result = $this->validate_database_file( $temp_out, $bytes_written );
		if ( is_wp_error( $validation_result ) ) {
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', $validation_result->get_error_message() );
			return false;
		}

		// Atomic replace: move temp file into place.
		if ( ! rename( $temp_out, $this->get_database_path() ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
			$this->cleanup_temp_files( $temp_dl, $temp_out );
			$this->update_status( 'error', __( 'Failed to write database file.', 'personalizewp' ) );
			return false;
		}

		// Remove the downloaded compressed file.
		$this->cleanup_temp_files( $temp_dl );

		$this->update_status( 'ready' );
		return true;
	}

	/**
	 * Remove temporary files created during download.
	 *
	 * @since 3.5.0
	 *
	 * @param string ...$files File paths to remove.
	 */
	private function cleanup_temp_files( ...$files ) {
		foreach ( $files as $file ) {
			if ( file_exists( $file ) ) {
				wp_delete_file( $file );
			}
		}
	}

	/**
	 * Schedule a one-off background download of the geo database.
	 *
	 * Uses Action Scheduler if available (e.g. via WooCommerce) for reliable
	 * background processing, otherwise falls back to WP-Cron.
	 *
	 * @since 3.5.0
	 */
	public function schedule_download() {
		$this->update_status( 'downloading', __( 'Geolocation database is downloading in the background.', 'personalizewp' ) );

		if ( function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action( self::CRON_HOOK, [], 'personalizewp' );
		} else {
			wp_schedule_single_event( time(), self::CRON_HOOK );
		}
	}

	/**
	 * Display an admin notice while the geo database is downloading or if an error occurred.
	 *
	 * @since 3.5.0
	 */
	public function admin_notice() {
		$status = $this->get_database_status();

		if ( 'downloading' === $status['status'] ) {
			$notice_content = sprintf(
				'<strong>PersonalizeWP:</strong> %s',
				esc_html__( 'We\'re downloading the DB-IP Geolocation database in the background. You can continue to use the plugin whilst this completes, but country/region/state-based personalisation will not be available until it is ready.', 'personalizewp' )
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_admin_notice returns safe HTML.
			echo wp_get_admin_notice(
				$notice_content,
				[
					'type'               => 'info',
					'additional_classes' => [ 'notice' ],
				]
			);
		} elseif ( 'error' === $status['status'] ) {
			$error_text = esc_html(
				sprintf(
					/* translators: %s: Error message */
					__( 'Geolocation database download failed: %s', 'personalizewp' ),
					$status['message']
				)
			);

			$notice_content = sprintf(
				'<strong>PersonalizeWP:</strong> %s <button type="button" class="button button-small pwp-retry-geo-download">%s</button>',
				$error_text,
				esc_html__( 'Retry Download', 'personalizewp' )
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_admin_notice returns safe HTML.
			echo wp_get_admin_notice(
				$notice_content,
				[
					'type'               => 'error',
					'additional_classes' => [ 'notice' ],
				]
			);

			$this->inline_retry_script();
		}
	}

	/**
	 * Output inline JavaScript for the retry download button.
	 *
	 * @since 3.5.0
	 */
	private function inline_retry_script() {
		?>
		<script>
		(function() {
			document.addEventListener( 'click', function( e ) {
				if ( ! e.target.classList.contains( 'pwp-retry-geo-download' ) ) {
					return;
				}
				var btn = e.target;
				btn.disabled = true;
				btn.textContent = '<?php echo esc_js( __( 'Retrying…', 'personalizewp' ) ); ?>';

				var data = new FormData();
				data.append( 'action', 'pwp_retry_geo_download' );
				data.append( '_ajax_nonce', '<?php echo esc_js( wp_create_nonce( 'pwp-ajax-nonce' ) ); ?>' );

				fetch( '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>', {
					method: 'POST',
					credentials: 'same-origin',
					body: data
				} ).then( function( response ) {
					return response.json();
				} ).then( function( result ) {
					var notice = btn.closest( '.notice' );
					if ( result.success && notice ) {
						notice.style.display = 'none';
					} else {
						btn.disabled = false;
						btn.textContent = '<?php echo esc_js( __( 'Retry Download', 'personalizewp' ) ); ?>';
					}
				} ).catch( function() {
					btn.disabled = false;
					btn.textContent = '<?php echo esc_js( __( 'Retry Download', 'personalizewp' ) ); ?>';
				} );
			} );
		})();
		</script>
		<?php
	}

	/**
	 * Handle the retry download AJAX action.
	 *
	 * @since 3.5.0
	 */
	public function handle_retry_download() {
		check_ajax_referer( 'pwp-ajax-nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'You do not have permission to do this.', 'personalizewp' ), 403 );
		}

		$this->schedule_download();

		wp_send_json_success();
	}

	/**
	 * Unschedule the WP-Cron event.
	 *
	 * @since 3.5.0
	 */
	public function unschedule_updates() {
		$timestamp = wp_next_scheduled( self::CRON_HOOK );
		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, self::CRON_HOOK );
		}
	}

	/**
	 * Delete the database file. Used on plugin uninstall.
	 *
	 * @since 3.5.0
	 */
	public function delete_database() {
		$path = $this->get_database_path();
		if ( file_exists( $path ) ) {
			wp_delete_file( $path );
		}
		$this->cleanup_temp_files( ...$this->get_temp_file_paths() );
		delete_option( self::STATUS_OPTION );
	}

	/**
	 * Get current database status.
	 *
	 * @since 3.5.0
	 *
	 * @return array {
	 *     @type string $status     'ready', 'downloading', 'error', or 'none'.
	 *     @type string $message    Human-readable status message.
	 *     @type int    $updated_at Unix timestamp of last successful update.
	 * }
	 */
	public function get_database_status() {
		$default = [
			'status'     => 'none',
			'message'    => '',
			'updated_at' => 0,
			'started_at' => 0,
		];
		$status = get_option( self::STATUS_OPTION, $default );

		if ( ! is_array( $status ) ) {
			return $default;
		}

		return wp_parse_args( $status, $default );
	}

	/**
	 * Generate the download URL for the current month's DB-IP Lite City MMDB.
	 *
	 * @since 3.5.0
	 *
	 * @return string Download URL.
	 */
	private function get_download_url() {
		$year_month = gmdate( 'Y-m' );
		return "https://download.db-ip.com/free/dbip-city-lite-{$year_month}.mmdb.gz";
	}

	/**
	 * Update the stored database status.
	 *
	 * @since 3.5.0
	 *
	 * @param string $status  Status key: 'ready', 'downloading', or 'error'.
	 * @param string $message Optional. Human-readable message.
	 */
	private function update_status( $status, $message = '' ) {
		$current_status = $this->get_database_status();

		update_option(
			self::STATUS_OPTION,
			[
				'status'     => $status,
				'message'    => $message,
				'updated_at' => 'ready' === $status ? time() : (int) $current_status['updated_at'],
				'started_at' => 'downloading' === $status ? time() : 0,
			],
			false
		);
	}

	/**
	 * Determine whether a download status is stale and should be retried.
	 *
	 * @since 3.5.0
	 *
	 * @param array $status Current database status.
	 *
	 * @return bool
	 */
	private function is_download_stale( $status ) {
		if ( 'downloading' !== $status['status'] ) {
			return false;
		}

		if ( empty( $status['started_at'] ) ) {
			return true;
		}

		return ( time() - (int) $status['started_at'] ) > self::DOWNLOAD_STALE_TTL;
	}

	/**
	 * Validate a decompressed MMDB file before it becomes live.
	 *
	 * @since 3.5.0
	 *
	 * @param string $path          Temp file path.
	 * @param int    $bytes_written Number of bytes written during decompression.
	 *
	 * @return true|\WP_Error
	 */
	private function validate_database_file( $path, $bytes_written ) {
		clearstatcache( true, $path );

		$file_size = file_exists( $path ) ? filesize( $path ) : false;
		if ( false === $file_size ) {
			return new \WP_Error( 'pwp_geo_db_invalid', __( 'The downloaded geolocation database could not be validated.', 'personalizewp' ) );
		}

		if ( $file_size !== $bytes_written ) {
			return new \WP_Error( 'pwp_geo_db_size_mismatch', __( 'The downloaded geolocation database was incomplete and has been discarded.', 'personalizewp' ) );
		}

		if ( $file_size < self::MIN_VALID_DATABASE_BYTES ) {
			return new \WP_Error(
				'pwp_geo_db_too_small',
				sprintf(
					/* translators: %s: minimum expected database size in MB */
					__( 'The downloaded geolocation database was smaller than the expected %s MB minimum.', 'personalizewp' ),
					number_format_i18n( self::MIN_VALID_DATABASE_BYTES / MB_IN_BYTES )
				)
			);
		}

		try {
			$reader = new \GeoIp2\Database\Reader( $path );
			$reader->city( '8.8.8.8' );
			$reader->close();
		} catch ( \Exception $e ) {
			return new \WP_Error(
				'pwp_geo_db_reader_failed',
				sprintf(
					/* translators: %s: validation error message */
					__( 'The downloaded geolocation database failed validation: %s', 'personalizewp' ),
					$e->getMessage()
				)
			);
		}

		return true;
	}

	/**
	 * Get the temporary download and decompression file paths.
	 *
	 * @since 3.5.0
	 *
	 * @return string[]
	 */
	private function get_temp_file_paths() {
		$path = $this->get_database_path();

		return [
			$path . '.download',
			$path . '.tmp',
		];
	}
}
