<?php

use WPMedia\BackWPup\Backup\FailureContext\Dropbox\DropboxFailureContextMapper;
use WPMedia\BackWPup\Backup\ReasonCode;

/**
 * This class allows the user to back up to Dropbox.
 *
 * Documentation: https://www.dropbox.com/developers/documentation/http/overview
 */
class BackWPup_Destination_Dropbox extends BackWPup_Destinations {

	/**
	 * Service name
	 *
	 * @var string
	 */
	private const SERVICE_NAME = 'Dropbox';

	/**
	 * Dropbox.
	 *
	 * Instance of Dropbox API
	 *
	 * @var BackWPup_Destination_Dropbox_API|null
	 */
	protected $dropbox;

	/**
	 * Default Options.
	 *
	 * @return array The default options for dropbox
	 */
	public function option_defaults(): array {
		return [
			'dropboxtoken'        => [],
			'dropboxroot'         => 'sandbox',
			'dropboxmaxbackups'   => 15,
			'dropboxsyncnodelete' => true,
			'dropboxdir'          => '/' . trailingslashit( sanitize_file_name( get_bloginfo( 'name' ) ) ),
		];
	}

	/**
	 * {@inheritdoc}
	 *
	 * @param int|array $jobid the job id.
	 *
	 * @throws BackWPup_Destination_Dropbox_API_Exception If the destionation instance cannot be created.
	 *
	   * @phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
	 */
	public function edit_form_post_save( $jobid ): void {
				$jobids = (array) $jobid;
		// Bet auth.
		if ( ! empty( $_POST['sandbox_code'] ) ) {
			try {
				$dropbox      = new BackWPup_Destination_Dropbox_API( 'sandbox' );
				$dropboxtoken = $dropbox->o_auth_token( $_POST['sandbox_code'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
				foreach ( $jobids as $id ) {
					BackWPup_Option::update( $id, 'dropboxtoken', $dropboxtoken );
					BackWPup_Option::update( $id, 'dropboxroot', 'sandbox' );
				}
			} catch ( Exception $e ) {
				BackWPup_Admin::message( 'DROPBOX: ' . $e->getMessage(), true );
								throw $e;
			}
		}

		if ( ! empty( $_POST['dropbbox_code'] ) ) {
			try {
				$dropbox      = new BackWPup_Destination_Dropbox_API( 'dropbox' );
				$dropboxtoken = $dropbox->o_auth_token( $_POST['dropbbox_code'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
				foreach ( $jobids as $id ) {
					BackWPup_Option::update( $id, 'dropboxtoken', $dropboxtoken );
					BackWPup_Option::update( $id, 'dropboxroot', 'dropbox' );
				}
			} catch ( Exception $e ) {
				BackWPup_Admin::message( 'DROPBOX: ' . $e->getMessage(), true );
								throw $e;
			}
		}
				$_POST['dropboxdir'] = trailingslashit(
					str_replace( '//', '/', str_replace( '\\', '/', trim( sanitize_text_field( $_POST['dropboxdir'] ) ) ) ) // phpcs:ignore WordPress.Security
				);
		if ( '/' === $_POST['dropboxdir'] ) {
			$_POST['dropboxdir'] = '';
		}

		// Delete auth.
		if ( ! empty( $_POST['delete_auth'] ) ) {
			// We need to check if the token is used on another job.
			$temp_jobs_ids                  = BackWPup_Option::get_job_ids();
			$job_token                      = BackWPup_Option::get( $jobids[0], 'dropboxtoken' );
			$is_job_token_used_on_other_job = false;
			foreach ( $temp_jobs_ids as $id ) {
				// We need to cast the id to int, because the job id is sometimes a string.
				if ( (int) $id !== (int) $jobids[0] && BackWPup_Option::get( $id, 'dropboxtoken' ) === $job_token ) {
					$is_job_token_used_on_other_job = true;
					break;
				}
			}
			// If the token is used on another job, we don't need to revoke it.
			if ( ! $is_job_token_used_on_other_job ) {
				// Try to revoke the token on dropbox.
				try {
					$dropbox = $this->get_dropbox( $jobids[0] );
					$dropbox->auth_token_revoke();
				} catch ( Exception $e ) {
					BackWPup_Admin::message(
						sprintf(
							// translators: %s is the error message.
							__( 'Failed to revoke Dropbox token: %s', 'backwpup' ),
							esc_html( $e->getMessage() )
						),
						true
					);
				}
			}
			// We delete the token from the jobs and seet the root to sandbox.
			foreach ( $jobids as $id ) {
				BackWPup_Option::update( $id, 'dropboxtoken', [] );
				BackWPup_Option::update( $id, 'dropboxroot', 'sandbox' );
			}
		}

		// Save settings.
		foreach ( $jobids as $id ) {
			BackWPup_Option::update( $id, 'dropboxsyncnodelete', ! empty( $_POST['dropboxsyncnodelete'] ) );
			BackWPup_Option::update(
				$id,
				'dropboxmaxbackups',
				isset( $_POST['dropboxmaxbackups'] ) && is_numeric( $_POST['dropboxmaxbackups'] ) ? absint( $_POST['dropboxmaxbackups'] ) : $this->option_defaults()['dropboxmaxbackups']
			);
			BackWPup_Option::update( $id, 'dropboxdir', $_POST['dropboxdir'] );
		}
	}
	// phpcs:enable

	/**
	 * Delete File.
	 *
	 * @param string $jobdest    The destination for this job.
	 * @param string $backupfile The file to delete.
	 */
	public function file_delete( string $jobdest, string $backupfile ): void {
		$files          = get_site_transient( 'backwpup_' . strtolower( $jobdest ) );
		[$jobid, $dest] = explode( '_', $jobdest );

		try {
			$dropbox = $this->get_dropbox( $jobid );
			$dropbox->files_delete( [ 'path' => $backupfile ] );

			// Update file list.
			foreach ( $files as $key => $file ) {
				if ( is_array( $file ) && $backupfile === $file['file'] ) {
					unset( $files[ $key ] );
				}
			}
			unset( $dropbox );
		} catch ( Exception $e ) {
			BackWPup_Admin::message( 'DROPBOX: ' . $e->getMessage(), true );
		}

		$key = 'backwpup_' . strtolower( $jobdest );

		/**
		 * Fires after jobs is updated.
		 *
		 * @since 5.2.1
		 *
		 * @param string $key The newly backup reference key.
		 * @param array $files An array of files data.
		 */
		do_action( 'backwpup_update_backup_history', $key, $files );
	}

	/**
	 * File Update List.
	 *
	 * Update the list of files in the transient.
	 *
	 * @param BackWPup_Job|int $job    Either the job object or job ID.
	 * @param bool             $delete Whether to delete old backups.
	 */
	public function file_update_list( $job, bool $delete = false ): void {
		if ( $job instanceof BackWPup_Job ) {
			$job_object = $job;
			$jobid      = $job->job['jobid'];
		} else {
			$job_object = null;
			$jobid      = $job;
		}

		$backupfilelist = [];
		$filecounter    = 0;
		$files          = [];
		$dropbox        = $this->get_dropbox( $jobid );
		$files_list     = $dropbox->list_folder( BackWPup_Option::get( $jobid, 'dropboxdir' ) );

		foreach ( $files_list as $data ) {
			if ( 'file' === $data['.tag'] && $this->is_backup_owned_by_job( $data['name'], $jobid ) ) {
				$file = $data['name'];
				if ( $this->is_backup_archive( $file ) ) {
					$backupfilelist[ strtotime( (string) $data['server_modified'] ) ] = $file;
				}
				$files[ $filecounter ]['folder']      = dirname( (string) $data['path_display'] );
				$files[ $filecounter ]['file']        = $data['path_display'];
				$files[ $filecounter ]['filename']    = $data['name'];
				$files[ $filecounter ]['downloadurl'] = network_admin_url(
					'admin.php?page=backwpupbackups&action=downloaddropbox&file=' . $data['path_display'] . '&local_file=' . $data['name'] . '&jobid=' . $jobid
				);
				$files[ $filecounter ]['filesize']    = $data['size'];
				$files[ $filecounter ]['time']        = strtotime( (string) $data['server_modified'] );
				++$filecounter;
			}
		}
		if ( $delete && $job_object && BackWPup_Option::get( $jobid, 'dropboxmaxbackups' ) > 0 ) { // Delete old backups.
			if ( count( $backupfilelist ) > $job_object->job['dropboxmaxbackups'] ) {
				ksort( $backupfilelist );
				$numdeltefiles = 0;
				$deleted_files = [];

				while ( ! empty( $backupfilelist ) ) {
					$file = array_shift( $backupfilelist );
					if ( count( $backupfilelist ) < $job_object->job['dropboxmaxbackups'] ) {
						break;
					}
					$response = $dropbox->files_delete(
						[ 'path' => $job_object->job['dropboxdir'] . $file ]
					); // Delete files on Cloud.

					foreach ( $files as $key => $filedata ) {
						if ( $filedata['file'] === $job_object->job['dropboxdir'] . $file
						&& ! empty( $response )
						) {
							$deleted_files[] = $filedata['filename'];
							unset( $files[ $key ] );
							break;
						}
					}
					++$numdeltefiles;
				}
				if ( $numdeltefiles > 0 ) {
					$job_object->log(
						sprintf(
							// translators: %d: number of files.
							_n(
								'%d file deleted from Dropbox',
								'%d files deleted on Dropbox',
								$numdeltefiles,
								'backwpup'
							),
							$numdeltefiles
						),
						E_USER_NOTICE
					);
				}

				parent::remove_file_history_from_database( $deleted_files, 'DROPBOX' );
			}
		}
		$key = 'backwpup_' . $jobid . '_dropbox';

		/**
		 * Fires after jobs is updated.
		 *
		 * @since 5.2.1
		 *
		 * @param string $key The newly backup reference key.
		 * @param array $files An array of files data.
		 */
		do_action( 'backwpup_update_backup_history', $key, $files );
	}

	/**
	 * Run the archive job for Dropbox.
	 *
	 * @param BackWPup_Job $job_object Job object.
	 *
	 * @return bool
	 */
	public function job_run_archive( BackWPup_Job $job_object ): bool {
		$job_object->substeps_todo = 2 + $job_object->backup_filesize;
		if ( $job_object->steps_data[ $job_object->step_working ]['SAVE_STEP_TRY'] !== $job_object->steps_data[ $job_object->step_working ]['STEP_TRY'] ) {
			$job_object->log(
				sprintf(
					/* translators: %d: attempt number. */
					__( '%d. Try to send backup file to Dropbox&#160;&hellip;', 'backwpup' ),
					$job_object->steps_data[ $job_object->step_working ]['STEP_TRY']
				)
			);
		}

		try {
			$dropbox = $this->get_dropbox( $job_object );

			// Get account info.
			if ( $job_object->steps_data[ $job_object->step_working ]['SAVE_STEP_TRY'] !== $job_object->steps_data[ $job_object->step_working ]['STEP_TRY'] ) {
				$info = $dropbox->users_get_current_account();
				if ( ! empty( $info['account_id'] ) ) {
					if ( $job_object->is_debug() ) {
						$user = $info['name']['display_name'] . ' (' . $info['email'] . ')';
					} else {
						$user = $info['name']['display_name'];
					}
					$job_object->log(
						sprintf(
						/* translators: %s: Dropbox user. */
						__( 'Authenticated with Dropbox of user: %s', 'backwpup' ),
						$user
					)
						);

					// Quota.
					if ( $job_object->is_debug() ) {
						$quota              = $dropbox->users_get_space_usage();
						$dropbox_free_space = $this->free_space_from_quota( is_array( $quota ) ? $quota : [] );
						if ( null !== $dropbox_free_space ) {
							$job_object->log(
								sprintf(
									/* translators: %s: available space. */
									__( '%s available on your Dropbox', 'backwpup' ),
									size_format( $dropbox_free_space, 2 )
								)
							);
						} else {
							$job_object->log( __( 'Dropbox free space could not be determined from the quota response.', 'backwpup' ) );
						}
					}
				} else {
					$job_object->log(
						__( 'Not Authenticated with Dropbox!', 'backwpup' ),
						E_USER_ERROR,
						__FILE__,
						__LINE__,
						[
							'reason_code'   => ReasonCode::REASON_INCORRECT_LOGIN,
							'destination'   => 'DROPBOX',
							'provider_code' => 'not_authenticated',
						]
					);

					return false;
				}
				$job_object->log( __( 'Uploading to Dropbox&#160;&hellip;', 'backwpup' ) );
			}

			// Put the file.
			if ( $job_object->substeps_done <= $job_object->backup_filesize ) { // Only if upload not complete.
				$errors_before = $job_object->errors;
				$response      = $dropbox->upload(
					$job_object->backup_folder . $job_object->backup_file,
					$job_object->job['dropboxdir'] . $job_object->backup_file
				);
				if ( ! is_array( $response ) || ! isset( $response['size'] ) ) {
					if ( $job_object->errors > $errors_before ) {
						return false;
					}
					$fallback = $this->upload_failure_fallback( $job_object, $dropbox );
					$message  = $fallback['message'];
					$context  = $fallback['context'];

					if ( is_array( $response ) && ! empty( $response['error'] ) ) {
						$message .= ' ' . $response['error'];
					}
					$job_object->log(
						$message,
						E_USER_ERROR,
						__FILE__,
						__LINE__,
						$context
					);
					return false;
				}
				if ( $job_object->backup_filesize === $response['size'] ) {
					if ( ! empty( $job_object->job['jobid'] ) ) {
						BackWPup_Option::update(
							$job_object->job['jobid'],
						'lastbackupdownloadurl',
						network_admin_url(
							'admin.php'
						) . '?page=backwpupbackups&action=downloaddropbox&file=' . ltrim(
							(string) $response['path_display'],
							'/'
						) . '&jobid=' . $job_object->job['jobid']
						);
					}
					$job_object->substeps_done = 1 + $job_object->backup_filesize;
					$job_object->log(
					sprintf(
						/* translators: %s: destination path. */
						__( 'Backup transferred to %s', 'backwpup' ),
						$response['path_display']
					),
					E_USER_NOTICE
					);
				} else {
					if ( $response['size'] !== $job_object->backup_filesize ) {
						$job_object->log(
							__( 'Uploaded file size and local file size don\'t match.', 'backwpup' ),
							E_USER_ERROR
						);
					} else {
						$job_object->log(
							sprintf(
								/* translators: %s: destination service name. */
								__( 'Error transfering backup to %s.', 'backwpup' ) . ' ' . $response['error'],
								__( 'Dropbox', 'backwpup' )
							),
							E_USER_ERROR
						);
					}

					return false;
				}
			}

			$this->file_update_list( $job_object, true );
		} catch ( Exception $e ) {
			$job_object->log(
				sprintf(
					/* translators: %s: error message. */
					__( 'Dropbox API: %s', 'backwpup' ),
					$e->getMessage()
				),
				E_USER_ERROR,
				$e->getFile(),
				$e->getLine(),
				$this->error_context( $e )
			);

			return false;
		}

		++$job_object->substeps_done;

		return true;
	}

	/**
	 * Check if Dropbox destination can run.
	 *
	 * @param array $job_settings
	 * @param bool  $test_connection Job settings.
	 *
	 * @return bool
	 */
	public function can_run( array $job_settings, bool $test_connection = true ): bool {
		return ! ( empty( $job_settings['dropboxtoken'] ) );
	}

	/**
	 * Get Dropbox.
	 *
	 * Gets the Dropbox API instance.
	 *
	 * @param BackWPup_Job|int $job Either the job object or job ID.
	 *
	 * @return BackWPup_Destination_Dropbox_API
	 */
	protected function get_dropbox( $job ): BackWPup_Destination_Dropbox_API {
		if ( ! $this->dropbox ) {
			if ( $job instanceof BackWPup_Job ) {
				$this->dropbox = new BackWPup_Destination_Dropbox_API( $job->job['dropboxroot'], $job );
				$jobid         = $job->job['jobid'];
				$token         = $job->job['dropboxtoken'];
			} else {
				$this->dropbox = new BackWPup_Destination_Dropbox_API( BackWPup_Option::get( $job, 'dropboxroot' ) );
				$jobid         = $job;
				$token         = BackWPup_Option::get( $job, 'dropboxtoken' );
			}

			$this->dropbox->set_o_auth_tokens(
				$token,
				function ( $token ) use ( $jobid ): void {
					BackWPup_Option::update( $jobid, 'dropboxtoken', $token );
				}
				);
		}

		return $this->dropbox;
	}

	/**
	 * Get service name
	 */
	public function get_service_name(): string {
		return self::SERVICE_NAME;
	}

	/**
	 * Build error context for Dropbox errors.
	 *
	 * @param \Exception $e Exception object.
	 * @return array
	 */
	private function error_context( \Exception $e ): array {
		if ( $e instanceof BackWPup_Destination_Dropbox_API_Exception ) {
			$context = $e->getContext();
			if ( ! empty( $context ) ) {
				return $context;
			}
		}

		$mapper = new DropboxFailureContextMapper();

		return $mapper->map_reason( ReasonCode::REASON_UNKNOWN_ERROR );
	}

	/**
	 * Build the narrow upload fallback used only when the API layer did not log a structured failure.
	 *
	 * @param BackWPup_Job                     $job_object Current job object.
	 * @param BackWPup_Destination_Dropbox_API $dropbox Dropbox API instance.
	 * @return array
	 */
	private function upload_failure_fallback( BackWPup_Job $job_object, BackWPup_Destination_Dropbox_API $dropbox ): array {
		$fallback = [
			'message' => __( 'Dropbox API: Upload failed.', 'backwpup' ),
			'context' => [
				'destination' => 'DROPBOX',
			],
		];

		try {
			$quota = $dropbox->users_get_space_usage();
		} catch ( Exception $exception ) {
			$quota = null;
		}

		$free_space = is_array( $quota ) ? $this->free_space_from_quota( $quota ) : null;
		if ( null !== $free_space && $job_object->backup_filesize > $free_space ) {
			$fallback = [
				'message' => __( 'You do not have enough space in your Dropbox.', 'backwpup' ),
				'context' => [
					'reason_code'   => ReasonCode::REASON_NOT_ENOUGH_STORAGE,
					'destination'   => 'DROPBOX',
					'provider_code' => 'insufficient_space',
				],
			];
		}

		return $fallback;
	}

	/**
	 * Extract available Dropbox space from the quota payload.
	 *
	 * @param array $quota Dropbox quota payload.
	 * @return int|null
	 */
	private function free_space_from_quota( array $quota ): ?int {
		if (
			! isset( $quota['allocation']['allocated'], $quota['used'] )
			|| ! is_numeric( $quota['allocation']['allocated'] )
			|| ! is_numeric( $quota['used'] )
		) {
			return null;
		}

		return max( 0, (int) $quota['allocation']['allocated'] - (int) $quota['used'] );
	}
}
