<?php
/**
 * Activation / deactivation handler. Creates custom DB tables.
 *
 * @package Backreload
 */

namespace Backreload;

if ( ! defined( 'ABSPATH' ) ) { exit; }

final class Installer {

	public static function activate(): void {
		self::create_tables();
		self::ensure_columns();
		self::ensure_indices();
		self::seed_defaults();
		self::migrate_legacy_email_notifier();
		// Security cleanup: earlier builds froze a copy of the WordPress auth
		// constants (AUTH_KEY/SALT material) in this option to survive key
		// rotation. We no longer derive keys from — or persist — any auth
		// material (encryption is now master-key-only), so remove the stored
		// secret from any site that upgraded from such a build.
		delete_option( 'backreload_auth_material_v2' );
		update_option( 'backreload_db_version', BACKRELOAD_DB_VER );
		// Pre-empt the LiteSpeed / mod_security 406 on chunked import by
		// writing a narrow .htaccess exemption. No-op on servers that don't
		// read .htaccess (nginx) — those users see the equivalent snippet on
		// the System page.
		\Backreload\Core\WafExemption::install();
	}

	/**
	 * One-shot migration: the Settings page used to host an "email
	 * notifications" card with `notify_email`, `notify_on_failure`,
	 * `notify_on_success` keys. That card was replaced by the multi-channel
	 * Notifications sidebar page, which stores each channel as a record
	 * inside `backreload_notifiers`. The Settings keys then went dead — saved
	 * but never read — so any user who'd configured "email on failure"
	 * silently lost their notifications when the rich page shipped.
	 *
	 * This migration runs on every activate (which `maybe_upgrade()` triggers
	 * on version drift). It's idempotent: if an email notifier of any shape
	 * already exists, we skip — no risk of duplicating channels for a user
	 * who's already set one up manually on the new page.
	 *
	 * Run conditions (ALL must be true):
	 *   1. `notify_email` option holds a valid email
	 *   2. `backreload_notifiers` doesn't already contain a row with type=email
	 *   3. NotifierManager class is loadable (defence-in-depth — shouldn't
	 *      fail on a normal install, but a half-deactivated plugin file tree
	 *      shouldn't throw fatals here)
	 *
	 * On success, drops a single info log so the admin sees what happened.
	 * The original `notify_*` keys are left in `wp_options` as harmless
	 * cruft — deleting them is risk for zero benefit, and leaving them lets
	 * a forensic admin see what the old config was.
	 */
	public static function migrate_legacy_email_notifier(): void {
		$settings = (array) get_option( 'backreload_settings', [] );
		$email    = (string) ( $settings['notify_email'] ?? '' );
		if ( '' === $email || ! is_email( $email ) ) {
			return;
		}

		$existing = (array) get_option( \Backreload\Notifiers\NotifierManager::OPTION, [] );
		foreach ( $existing as $row ) {
			if ( isset( $row['type'] ) && 'email' === $row['type'] ) {
				return; // user already has an email channel set up
			}
		}

		$on_failure = (bool) ( $settings['notify_on_failure'] ?? true );
		$on_success = (bool) ( $settings['notify_on_success'] ?? false );

		$record = [
			'id'         => 'n_' . wp_generate_password( 10, false ),
			'type'       => 'email',
			'name'       => 'Email (migrated)',
			'enabled'    => true,
			'on_success' => $on_success,
			'on_failure' => $on_failure,
			'config'     => [ 'to' => $email ],
		];

		// Encrypt config the same way NotifierManager::save_configured() does
		// so the row round-trips through `configured()`. Wrap in try/catch
		// because Encryption depends on the plugin container which may not
		// be fully booted during early activation.
		try {
			$enc = \backreload()->get( 'encryption' );
			if ( $enc ) {
				$record['config'] = $enc->encrypt_config( $record['config'] );
			}
		} catch ( \Throwable $e ) {
			// Encryption unavailable: store the recipient unencrypted. The
			// notifier code handles both shapes (configured() tries to decrypt
			// and returns the raw value if decryption fails). Acceptable
			// fallback for a single email address — not a secret.
		}

		$existing[] = $record;
		update_option( \Backreload\Notifiers\NotifierManager::OPTION, array_values( $existing ) );

		// Use error_log() rather than the plugin logger because Logger may
		// not be fully wired yet at this point in the activation lifecycle.
		// Admins who want to verify the migration can grep their PHP error log.
		// Gated on WP_DEBUG so production sites stay quiet.
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic only when WP_DEBUG is on; one-time activation event admins may want to audit.
			@error_log( "Backreload: migrated legacy email notification ({$email}) into Notifications page." );
		}
	}

	/**
	 * Belt-and-braces ALTER TABLE for new columns. `dbDelta` is finicky about
	 * whitespace and silently skips ALTERs sometimes — explicit ADD COLUMN is
	 * more reliable for incremental schema upgrades.
	 */
	public static function ensure_columns(): void {
		global $wpdb;
		$table = $wpdb->prefix . 'backreload_backups';

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Interpolated identifier is a table name derived from $wpdb->prefix (never user input) and cannot be bound via prepare(); all data values use %s/%d placeholders.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time schema setup/upgrade of the plugin's own tables on activation.

		// Bail if the table itself doesn't exist yet — create_tables() will own it.
		$exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( '' === $exists ) return;

		$cols = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );

		if ( ! in_array( 'source_url', $cols, true ) ) {
			$wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN source_url VARCHAR(255) NULL AFTER error" );
		}
		if ( ! in_array( 'source_path', $cols, true ) ) {
			$wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN source_path VARCHAR(500) NULL AFTER source_url" );
		}
		if ( ! in_array( 'files', $cols, true ) ) {
			// Multi-zip: holds an array of {name, category, size, checksum, encrypted, uploaded_to[]}
			$wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN files LONGTEXT NULL AFTER components" );
		}
		if ( ! in_array( 'kind', $cols, true ) ) {
			// Distinguishes manual backups, scheduled backups, and pre-restore snapshots.
			// Snapshots are hidden from the main Backups list by default + auto-pruned.
			$wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'manual' AFTER type" );
		}

		// ── destinations table ─────────────────────────────────────────
		$dest_table = $wpdb->prefix . 'backreload_destinations';
		$dest_exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $dest_table ) );
		if ( '' !== $dest_exists ) {
			$dest_cols = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$dest_table}`" );
			if ( ! in_array( 'paused', $dest_cols, true ) ) {
				// Boolean "user has temporarily disabled this destination". The
				// engine + retention skip paused destinations entirely, without
				// touching their stored config.
				$wpdb->query( "ALTER TABLE `{$dest_table}` ADD COLUMN paused TINYINT(1) NOT NULL DEFAULT 0 AFTER status" );
			}
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
	}

	/**
	 * Add composite indices that the hot query patterns need. Single-column
	 * indices on `status` and `created_at` already exist; the queries we
	 * actually run filter on (type, status) and order by id/created_at, which
	 * benefit hugely from a composite. Idempotent — re-adding silently no-ops.
	 */
	public static function ensure_indices(): void {
		global $wpdb;
		$backups = $wpdb->prefix . 'backreload_backups';
		$logs    = $wpdb->prefix . 'backreload_logs';

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Interpolated identifier is a table name derived from $wpdb->prefix (never user input) and cannot be bound via prepare(); all data values use %s/%d placeholders.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time schema setup/upgrade of the plugin's own tables on activation.

		// Snapshot existing indices once per table so we don't re-create them.
		$has_index = function ( string $table, string $name ) use ( $wpdb ): bool {
			$row = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM `{$table}` WHERE Key_name = %s", $name ), ARRAY_A );
			return is_array( $row ) && count( $row ) > 0;
		};

		if ( $has_index( $backups, 'type_created' ) === false ) {
			$wpdb->query( "ALTER TABLE `{$backups}` ADD KEY type_created (type, created_at)" );
		}
		if ( $has_index( $backups, 'status_created' ) === false ) {
			$wpdb->query( "ALTER TABLE `{$backups}` ADD KEY status_created (status, created_at)" );
		}

		// Logs: composite for the BackupProgress poller pattern
		// (WHERE backup_ext_id = ? ORDER BY id DESC).
		if ( $has_index( $logs, 'backup_id_id' ) === false ) {
			$wpdb->query( "ALTER TABLE `{$logs}` ADD KEY backup_id_id (backup_ext_id, id)" );
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
	}

	public static function deactivate(): void {
		// Clear all per-schedule cron events + the legacy single hook.
		wp_clear_scheduled_hook( 'backreload_run' );
		wp_clear_scheduled_hook( \Backreload\Core\Scheduler::HOOK_EXECUTE );

		$crons = _get_cron_array();
		if ( is_array( $crons ) ) {
			foreach ( $crons as $timestamp => $hooks ) {
				if ( ! isset( $hooks[ \Backreload\Core\Scheduler::HOOK_RUN ] ) ) continue;
				foreach ( $hooks[ \Backreload\Core\Scheduler::HOOK_RUN ] as $event ) {
					wp_unschedule_event( $timestamp, \Backreload\Core\Scheduler::HOOK_RUN, $event['args'] );
				}
			}
		}
		// Remove the .htaccess marker block we wrote on activation — leaves
		// the user's .htaccess in a clean state.
		\Backreload\Core\WafExemption::remove();
	}

	public static function create_tables(): void {
		global $wpdb;

		$charset = $wpdb->get_charset_collate();
		$prefix  = $wpdb->prefix;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$tables = [
			"CREATE TABLE {$prefix}backreload_backups (
				id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				ext_id        VARCHAR(32)    NOT NULL,
				type          VARCHAR(20)    NOT NULL DEFAULT 'full',
				kind          VARCHAR(16)    NOT NULL DEFAULT 'manual',
				status        VARCHAR(20)    NOT NULL DEFAULT 'pending',
				size          BIGINT UNSIGNED NOT NULL DEFAULT 0,
				duration      INT UNSIGNED   NOT NULL DEFAULT 0,
				encrypted     TINYINT(1)     NOT NULL DEFAULT 0,
				destinations  TEXT           NULL,
				components    TEXT           NULL,
				files         LONGTEXT       NULL,
				checksum      VARCHAR(128)   NULL,
				error         TEXT           NULL,
				source_url    VARCHAR(255)   NULL,
				source_path   VARCHAR(500)   NULL,
				created_at    DATETIME       NOT NULL,
				completed_at  DATETIME       NULL,
				PRIMARY KEY  (id),
				UNIQUE KEY ext_id (ext_id),
				KEY status (status),
				KEY created_at (created_at)
			) {$charset};",

			"CREATE TABLE {$prefix}backreload_destinations (
				id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				ext_id        VARCHAR(32)    NOT NULL,
				type          VARCHAR(32)    NOT NULL,
				name          VARCHAR(190)   NOT NULL,
				config        LONGTEXT       NULL,
				status        VARCHAR(20)    NOT NULL DEFAULT 'disconnected',
				paused        TINYINT(1)     NOT NULL DEFAULT 0,
				last_tested   DATETIME       NULL,
				created_at    DATETIME       NOT NULL,
				PRIMARY KEY  (id),
				UNIQUE KEY ext_id (ext_id),
				KEY type (type)
			) {$charset};",

			"CREATE TABLE {$prefix}backreload_logs (
				id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
				backup_ext_id VARCHAR(32)    NULL,
				level         VARCHAR(10)    NOT NULL DEFAULT 'info',
				message       TEXT           NOT NULL,
				context       LONGTEXT       NULL,
				created_at    DATETIME       NOT NULL,
				PRIMARY KEY  (id),
				KEY level (level),
				KEY backup_ext_id (backup_ext_id),
				KEY created_at (created_at)
			) {$charset};",
		];

		foreach ( $tables as $sql ) {
			dbDelta( $sql );
		}
	}

	public static function seed_defaults(): void {
		if ( ! get_option( 'backreload_settings' ) ) {
			// Dropped keys: `notify_email`, `notify_on_failure`,
			// `notify_on_success` (the Notifications sidebar page owns
			// notifications now), and `chunk_size_mb` (auto-tuned from
			// PHP's memory_limit by ArchiveWriter::recommended_chunk_mb()).
			// Existing installs may still carry these in their saved
			// settings — harmless, never read.
			add_option( 'backreload_settings', [
				'encryption'              => false,
				'compression_level'       => 6,
				'maintenance_on_restore'  => true,
				'log_retention_days'      => 30,
				'trust_forwarded_ip'      => false,
				'exclusions'              => [
					'wp-content/cache/*',
					'wp-content/uploads/backreload/*',
					'*.log',
				],
			] );
		}

		// Schedules use a new option key (backreload_schedules). Don't seed any
		// defaults — users explicitly create schedules from the UI.

		// Seed a default Local destination if none exists
		global $wpdb;
		$t = $wpdb->prefix . 'backreload_destinations';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name derived from $wpdb->prefix (never user input), cannot be bound via prepare(); plugin reads its own table, result is transient and uncacheable.
		$exists = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$t}" );
		if ( 0 === $exists ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin reads/writes its own custom tables; results are transient backup state and must not be cached.
			$wpdb->insert( $t, [
				'ext_id'     => 'd_' . wp_generate_password( 8, false ),
				'type'       => 'local',
				'name'       => 'Local Storage',
				'config'     => wp_json_encode( [ 'path' => \Backreload\Storage::root() ] ),
				'status'     => 'connected',
				'created_at' => current_time( 'mysql', true ),
			] );
		}

		// Create + harden the storage directory (uploads/backreload with a
		// deny-all .htaccess + silent index.php). Storage::root() handles this.
		\Backreload\Storage::root();
	}
}
