<?php
/**
 * BitForgeNet Guest Tickets - Archive and reset.
 *
 * Empties the live booking tables so the site is clean after testing, without ever
 * destroying anything: every order, attendee, ticket, scan and email is copied into
 * the archive tables first, grouped in a numbered batch you can look up later.
 *
 * Because this is the one irreversible-looking button in the plugin, it is gated by a
 * one-time code emailed to a fixed authorised address. Knowing the WordPress password
 * is not enough — you also have to be reading that mailbox.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.1.0
 */

defined( 'ABSPATH' ) || exit;

// This file talks to the plugin's own tables. WordPress has no API for them --
// they are not posts and not meta -- so $wpdb is the only way in. The rows
// change on every booking and every scan, which makes an object cache wrong
// more often than it is right. Table names are built from $wpdb->prefix and
// never from a request, so interpolating one carries nothing to inject; every
// value goes through $wpdb->prepare(), and the few identifiers that cannot
// (ORDER BY columns) are matched against a whitelist first.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter


/**
 * Archives bookings and resets the live tables.
 */
class BFNET_Archive {

	const CODE_TRANSIENT = 'bfnet_reset_code';
	const CODE_TTL       = 900;

	/**
	 * The address allowed to authorise a reset.
	 *
	 * @return string
	 */
	public static function authorised_email() {
		$email = sanitize_email( (string) BFNET_Settings::get( 'reset_authorised_email', '' ) );
		return $email && is_email( $email ) ? $email : '';
	}

	/* --------------------------------------------------------------------- */
	/* Passcode                                                              */
	/* --------------------------------------------------------------------- */

	/**
	 * Generate a one-time code and email it to the authorised address.
	 *
	 * @return true|WP_Error
	 */
	public static function request_code() {
		$to = self::authorised_email();

		if ( ! $to ) {
			return new WP_Error(
				'bfnet_no_authorised_email',
				__( 'Set the authorising email address in Settings before using this tool.', 'bitforgenet-guest-tickets' )
			);
		}

		if ( ! BFNET_Security::rate_limit( 'reset_code', 5, HOUR_IN_SECONDS, 'user:' . get_current_user_id() ) ) {
			return new WP_Error( 'bfnet_rate_limited', __( 'Too many code requests. Wait an hour and try again.', 'bitforgenet-guest-tickets' ) );
		}

		try {
			$code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT );
		} catch ( Exception $e ) {
			$code = str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
		}

		set_transient(
			self::CODE_TRANSIENT,
			array(
				'hash' => wp_hash_password( $code ),
				'user' => get_current_user_id(),
				'at'   => time(),
			),
			self::CODE_TTL
		);

		$user  = wp_get_current_user();
		$stats = BFNET_Order::stats( 0 );

		$body = '<h1 style="margin:0 0 16px;font-size:22px;color:#111">' . esc_html__( 'Confirmation code for archiving', 'bitforgenet-guest-tickets' ) . '</h1>'
			. '<p style="margin:0 0 18px;font-size:15px;line-height:1.65;color:#3A3A3A">'
			. sprintf(
				/* translators: 1: user display name, 2: site name. */
				esc_html__( '%1$s asked to archive and clear all bookings on %2$s.', 'bitforgenet-guest-tickets' ),
				esc_html( $user ? $user->display_name : '' ),
				esc_html( get_bloginfo( 'name' ) )
			)
			. '</p>'
			. '<div style="margin:22px 0;padding:20px;background:#FAFAFB;border:1px dashed #C9A227;border-radius:11px;text-align:center">'
			. '<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#8A8A8A;margin-bottom:7px">' . esc_html__( 'Confirmation code', 'bitforgenet-guest-tickets' ) . '</div>'
			. '<div style="font-size:34px;font-weight:700;letter-spacing:.24em;font-family:monospace;color:#111">' . esc_html( $code ) . '</div>'
			. '</div>'
			. '<p style="margin:0 0 12px;font-size:14px;line-height:1.6;color:#3A3A3A">'
			. sprintf(
				/* translators: 1: number of bookings, 2: number of tickets. */
				esc_html__( 'This will archive %1$d bookings and %2$d tickets, then empty the live tables. Nothing is deleted — everything stays readable in the archive.', 'bitforgenet-guest-tickets' ),
				(int) $stats['orders_total'],
				(int) $stats['tickets_total']
			)
			. '</p>'
			. '<p style="margin:0;font-size:13px;color:#8A8A8A">' . esc_html__( 'The code expires in 15 minutes. If you did not ask for this, ignore this email and change your WordPress password.', 'bitforgenet-guest-tickets' ) . '</p>';

		$sent = BFNET_Mailer::send(
			$to,
			__( 'BitForgeNet Guest Tickets — confirmation code for archiving', 'bitforgenet-guest-tickets' ),
			BFNET_Mailer::wrap( __( 'Confirmation code', 'bitforgenet-guest-tickets' ), $body, BFNET_Organizer::primary() ),
			array(),
			0,
			0,
			0,
			'reset_code'
		);

		BFNET_Log::security( 'reset_code_requested', array( 'to' => $to ) );

		if ( ! $sent ) {
			delete_transient( self::CODE_TRANSIENT );
			return new WP_Error( 'bfnet_mail_failed', __( 'The code could not be emailed. Check the delivery log.', 'bitforgenet-guest-tickets' ) );
		}

		return true;
	}

	/**
	 * Validate a submitted code.
	 *
	 * @param string $code Code typed by the operator.
	 * @return true|WP_Error
	 */
	public static function verify_code( $code ) {
		$stored = get_transient( self::CODE_TRANSIENT );

		if ( ! is_array( $stored ) || empty( $stored['hash'] ) ) {
			return new WP_Error( 'bfnet_no_code', __( 'No code is pending, or it has expired. Request a new one.', 'bitforgenet-guest-tickets' ) );
		}

		if ( (int) $stored['user'] !== get_current_user_id() ) {
			BFNET_Log::security( 'reset_code_wrong_user', array() );
			return new WP_Error( 'bfnet_wrong_user', __( 'That code was issued to a different account.', 'bitforgenet-guest-tickets' ) );
		}

		$code = preg_replace( '/\D/', '', (string) $code );

		if ( ! wp_check_password( $code, $stored['hash'] ) ) {
			BFNET_Log::security( 'reset_code_bad', array() );
			return new WP_Error( 'bfnet_bad_code', __( 'That code is not correct.', 'bitforgenet-guest-tickets' ) );
		}

		delete_transient( self::CODE_TRANSIENT );

		return true;
	}

	/* --------------------------------------------------------------------- */
	/* Archiving                                                             */
	/* --------------------------------------------------------------------- */

	/**
	 * Copy the live booking data into the archive, then empty it.
	 *
	 * @param array $args {
	 *     @type string $scope    'all' or 'event'.
	 *     @type int    $event_id Event to clear when scope is 'event'.
	 *     @type string $label    Human label for the batch.
	 *     @type string $reason   Why it was done.
	 *     @type bool   $files    Also delete the generated ticket PDFs.
	 * }
	 * @return array|WP_Error Counts per entity.
	 */
	public static function archive_and_reset( array $args ) {
		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'scope'    => 'all',
				'event_id' => 0,
				'label'    => '',
				'reason'   => '',
				'files'    => true,
			)
		);

		$event_id = (int) $args['event_id'];
		$scoped   = ( 'event' === $args['scope'] && $event_id > 0 );

		@set_time_limit( 600 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, Squiz.PHP.DiscouragedFunctions.Discouraged -- archiving and issuing run long on a large event; hosts that forbid this ignore it.

		$batches = BFNET_Install::table( 'archive_batches' );
		$records = BFNET_Install::table( 'archive_records' );
		$now     = current_time( 'mysql' );

		$label = $args['label'];
		if ( '' === trim( $label ) ) {
			$label = $scoped
				/* translators: %s: event title. */
				? sprintf( __( 'Reset of %s', 'bitforgenet-guest-tickets' ), self::event_title( $event_id ) )
				: __( 'Full reset', 'bitforgenet-guest-tickets' );
		}

		$wpdb->insert(
			$batches,
			array(
				'uuid'       => BFNET_Util::uuid(),
				'label'      => sanitize_text_field( $label ),
				'reason'     => sanitize_text_field( $args['reason'] ),
				'scope'      => $scoped ? 'event' : 'all',
				'event_id'   => $scoped ? $event_id : 0,
				'created_by' => get_current_user_id(),
				'created_at' => $now,
			)
		);

		$batch_id = (int) $wpdb->insert_id;
		if ( ! $batch_id ) {
			return new WP_Error( 'bfnet_batch_failed', __( 'The archive batch could not be created.', 'bitforgenet-guest-tickets' ) );
		}

		$counts = array();

		// Order matters: children first, so a failure never orphans anything.
		$entities = array(
			'scans'     => BFNET_Install::table( 'scans' ),
			'tickets'   => BFNET_Install::table( 'tickets' ),
			'attendees' => BFNET_Install::table( 'attendees' ),
			'emails'    => BFNET_Install::table( 'emails' ),
			'orders'    => BFNET_Order::table(),
		);

		foreach ( $entities as $entity => $table ) {
			$counts[ $entity ] = self::move( $entity, $table, $records, $batch_id, $scoped ? $event_id : 0, $now );
		}

		// Remove the rendered PDFs; they are reproducible from the archive if ever needed.
		if ( ! empty( $args['files'] ) ) {
			$counts['files'] = self::purge_ticket_files();
		}

		$wpdb->update(
			$batches,
			array( 'counts' => wp_json_encode( $counts ) ),
			array( 'id' => $batch_id ),
			array( '%s' ),
			array( '%d' )
		);

		// Put the event counters back to zero.
		if ( $scoped ) {
			BFNET_Order::refresh_event_counters( $event_id );
		} else {
			$wpdb->query( 'UPDATE ' . BFNET_Event::table() . ' SET sold_count = 0, checked_in_count = 0' ); // phpcs:ignore WordPress.DB.PreparedSQL
			$wpdb->query( 'UPDATE ' . BFNET_Install::table( 'ticket_types' ) . ' SET sold_count = 0' ); // phpcs:ignore WordPress.DB.PreparedSQL
		}

		BFNET_Log::audit(
			'system',
			$batch_id,
			'data_archived',
			array( 'scope' => $scoped ? 'event' : 'all', 'event_id' => $event_id, 'counts' => $counts ),
			'warning'
		);

		return $counts;
	}

	/**
	 * Copy one table's rows into the archive and delete them.
	 *
	 * @param string $entity   Entity name.
	 * @param string $table    Source table.
	 * @param string $records  Archive table.
	 * @param int    $batch_id Batch id.
	 * @param int    $event_id Optional event scope.
	 * @param string $now      Timestamp.
	 * @return int Rows moved.
	 */
	private static function move( $entity, $table, $records, $batch_id, $event_id, $now ) {
		global $wpdb;

		$moved  = 0;
		$has_ev = ( 'emails' !== $entity ) || true;

		while ( true ) {
			if ( $event_id && $has_ev ) {
				$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE event_id = %d LIMIT 200", $event_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL
			} else {
				$rows = $wpdb->get_results( "SELECT * FROM {$table} LIMIT 200", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL
			}

			if ( empty( $rows ) ) {
				break;
			}

			$ids = array();

			foreach ( $rows as $row ) {
				$wpdb->insert(
					$records,
					array(
						'batch_id'   => $batch_id,
						'entity'     => $entity,
						'entity_id'  => isset( $row['id'] ) ? (int) $row['id'] : 0,
						'event_id'   => isset( $row['event_id'] ) ? (int) $row['event_id'] : 0,
						'reference'  => isset( $row['reference'] ) ? substr( (string) $row['reference'], 0, 60 ) : ( isset( $row['code'] ) ? substr( (string) $row['code'], 0, 60 ) : '' ),
						'email'      => isset( $row['email'] ) ? substr( (string) $row['email'], 0, 190 ) : ( isset( $row['recipient'] ) ? substr( (string) $row['recipient'], 0, 190 ) : '' ),
						'payload'    => wp_json_encode( $row ),
						'created_at' => $now,
					)
				);

				$ids[] = (int) $row['id'];
				$moved++;
			}

			if ( empty( $ids ) ) {
				break;
			}

			$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
			$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a run of %d built by array_fill.

			// Safety valve against a pathological loop.
			if ( $moved > 200000 ) {
				break;
			}
		}

		return $moved;
	}

	/**
	 * Delete generated ticket PDFs.
	 *
	 * @return int Files removed.
	 */
	private static function purge_ticket_files() {
		$storage = BFNET_Install::storage();
		$files   = glob( $storage['dir'] . '/tickets/*.pdf' );

		if ( ! is_array( $files ) ) {
			return 0;
		}

		$count = 0;
		foreach ( $files as $file ) {
			if ( is_file( $file ) ) {
				wp_delete_file( $file );
				$count++;
			}
		}

		return $count;
	}

	/**
	 * Event title for labelling, without exploding if it is gone.
	 *
	 * @param int $event_id Event id.
	 * @return string
	 */
	private static function event_title( $event_id ) {
		$event = BFNET_Event::get( $event_id );
		return $event ? $event->title : ( '#' . (int) $event_id );
	}

	/* --------------------------------------------------------------------- */
	/* Reading the archive                                                   */
	/* --------------------------------------------------------------------- */

	/**
	 * Archive batches, newest first.
	 *
	 * @param int $limit How many.
	 * @return array
	 */
	public static function batches( $limit = 20 ) {
		global $wpdb;
		$table = BFNET_Install::table( 'archive_batches' );
		return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", (int) $limit ) ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Search archived bookings by reference, name or email.
	 *
	 * @param string $query Search term.
	 * @param int    $limit Maximum rows.
	 * @return array
	 */
	public static function search( $query, $limit = 50 ) {
		global $wpdb;

		$query = trim( (string) $query );
		if ( mb_strlen( $query ) < 2 ) {
			return array();
		}

		$table = BFNET_Install::table( 'archive_records' );
		$like  = '%' . $wpdb->esc_like( $query ) . '%';

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table}
				 WHERE entity = 'orders' AND ( reference LIKE %s OR email LIKE %s OR payload LIKE %s )
				 ORDER BY id DESC LIMIT %d",
				$like,
				$like,
				$like,
				(int) $limit
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Totals held in the archive.
	 *
	 * @return array
	 */
	public static function totals() {
		global $wpdb;

		$table = BFNET_Install::table( 'archive_records' );
		$rows  = $wpdb->get_results( "SELECT entity, COUNT(*) AS n FROM {$table} GROUP BY entity", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL

		$out = array();
		foreach ( (array) $rows as $row ) {
			$out[ $row['entity'] ] = (int) $row['n'];
		}

		return $out;
	}
}
