<?php
/**
 * Link Shortener Module
 *
 * Creates short URLs (yoursite.com/{prefix}/{code}), tracks clicks,
 * supports expiration and password protection. All UI in modal.
 *
 * @package LinkShortener
 * @since 1.0.0
 */

namespace LinkShortener;

use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\AbstractModule;
class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID           = 'link-shortener';
	private const MODULE_VERSION      = '1.3.0';
	private const TABLE_NAME          = 'wpps_short_links';
	private const ANALYTICS_TABLE     = 'wpps_short_links_analytics';
	private const QUERY_VAR           = 'wpps_short_code';
	private const CRON_HOOK           = 'wpps_link_shortener_analytics_cleanup';
	private const SHORT_CODE_MAX_LENGTH = 100;
	private const DEFAULT_SHORT_CODE_LENGTH = 6;
	private const DEFAULT_PER_PAGE    = 50;
	private const DEFAULT_ANALYTICS_RETENTION_DAYS = 14;
	private const ANALYTICS_EXPORT_MAX_ROWS = 50000;
	private const PASSWORD_ATTEMPT_LIMIT    = 5;
	private const PASSWORD_ATTEMPT_WINDOW   = MINUTE_IN_SECONDS;

	/** @var array<int> */
	private array $pending_clicks = [];

	private static bool $tables_ready = false;

	private ?array $settings_cache = null;

	/** @var array<string> */
	private const RESERVED_PREFIXES = [
		'wp-admin',
		'wp-content',
		'wp-includes',
		'wp-json',
		'feed',
		'xmlrpc.php',
		'login',
		'admin',
		'api',
	];

	/** @var array<string> */
	private const RESERVED_SHORT_CODES = [
		'wp-admin',
		'wp-login.php',
		'feed',
		'admin',
		'api',
		'login',
	];

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Link Shortener', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Short URLs on your site with click stats, optional expiry, and passwords—no external service.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax' ];
	}

	public function getDependencies(): array {
		return [];
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}


	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		add_action( self::CRON_HOOK, [ $this, 'pruneAnalytics' ] );

		if ( is_admin() ) {
		add_action( 'admin_init', [ $this, 'maybeCreateTable' ], 999 );
		add_action( 'admin_init', [ $this, 'maybeScheduleCron' ], 1000 );
		add_action( 'wp_dashboard_setup', [ $this, 'addDashboardWidget' ] );
		add_action( 'wp_ajax_wpps_link_shortener_list', [ $this, 'ajaxListLinks' ] );
		add_action( 'wp_ajax_wpps_link_shortener_get', [ $this, 'ajaxGetLink' ] );
		add_action( 'wp_ajax_wpps_link_shortener_save', [ $this, 'ajaxSaveLink' ] );
		add_action( 'wp_ajax_wpps_link_shortener_delete', [ $this, 'ajaxDeleteLink' ] );
		add_action( 'wp_ajax_wpps_link_shortener_bulk', [ $this, 'ajaxBulkAction' ] );
		add_action( 'wp_ajax_wpps_link_shortener_toggle', [ $this, 'ajaxToggleActive' ] );
		add_action( 'wp_ajax_wpps_link_shortener_export', [ $this, 'ajaxExportCsv' ] );
		add_action( 'wp_ajax_wpps_link_shortener_export_analytics', [ $this, 'ajaxExportAnalyticsCsv' ] );
		add_action( 'wp_ajax_wpps_link_shortener_import', [ $this, 'ajaxImportCsv' ] );
		add_action( 'wp_ajax_wpps_link_shortener_duplicate', [ $this, 'ajaxDuplicateLink' ] );
		add_action( 'wp_ajax_wpps_link_shortener_analytics', [ $this, 'ajaxGetAnalytics' ] );
		add_action( 'wp_ajax_nopriv_wpps_link_shortener_verify_password', [ $this, 'ajaxVerifyPassword' ] );
		add_action( 'wp_ajax_wpps_link_shortener_verify_password', [ $this, 'ajaxVerifyPassword' ] );
		add_action( 'add_meta_boxes', [ $this, 'addClassicEditorMetaBox' ] );
		add_action( 'enqueue_block_editor_assets', [ $this, 'enqueueBlockEditorAssets' ] );
		add_action( 'admin_footer', [ $this, 'maybeInlineShortLinkScript' ] );
		add_action( 'admin_footer', [ $this, 'maybeInlineRowActionsScript' ] );
		add_action( 'admin_head-post.php', [ $this, 'maybeShortLinkMetaBoxStyles' ] );
		add_filter( 'post_row_actions', [ $this, 'addPostRowActions' ], 10, 2 );
		add_filter( 'page_row_actions', [ $this, 'addPostRowActions' ], 10, 2 );
		}add_filter( 'query_vars', [ $this, 'addQueryVar' ] );
		add_action( 'init', [ $this, 'addRewriteRules' ], 10 );
		add_action( 'init', [ $this, 'maybeFlushRewriteRules' ], 999 );
		add_action( 'template_redirect', [ $this, 'handleShortLinkRedirect' ], 1 );
		add_action( 'wp_powersuite_module_settings_saved', [ $this, 'onSettingsSaved' ], 10, 2 );
		add_action( 'wp_powersuite_module_toggled', [ $this, 'onModuleToggled' ], 10, 2 );
	}


	public function onSettingsSaved( string $module_id, array $settings ): void {
		if ( $module_id === self::MODULE_ID ) {
			flush_rewrite_rules();
		}
	}

	public function onModuleToggled( string $module_id, bool $enable ): void {
		if ( $module_id !== self::MODULE_ID ) {
			return;
		}

		if ( $enable ) {
			flush_rewrite_rules();
			$this->maybeScheduleCron();
			return;
		}

		wp_clear_scheduled_hook( self::CRON_HOOK );
		flush_rewrite_rules();
	}

	public function maybeScheduleCron(): void {
		if ( ! $this->isEnabled() || wp_next_scheduled( self::CRON_HOOK ) ) {
			return;
		}

		wp_schedule_event( time(), 'daily', self::CRON_HOOK );
	}

	public function addQueryVar( array $vars ): array {
		$vars[] = self::QUERY_VAR;
		return $vars;
	}

	public function maybeFlushRewriteRules(): void {
		if ( get_transient( 'wpps_link_shortener_flush_rewrite' ) ) {
			delete_transient( 'wpps_link_shortener_flush_rewrite' );
			flush_rewrite_rules();
		}
	}

	public function addRewriteRules(): void {
		$prefix = $this->getUrlPrefix();
		$prefix = trim( (string) $prefix, '/' );
		if ( $prefix === '' ) {
			$prefix = 'link';
		}
		add_rewrite_rule(
			'^' . preg_quote( $prefix, '/' ) . '/([a-zA-Z0-9\-]+)/?$',
			'index.php?' . self::QUERY_VAR . '=$matches[1]',
			'top'
		);
	}

	public function handleShortLinkRedirect(): void {
		$code = get_query_var( self::QUERY_VAR );
		if ( $code === '' || $code === false ) {
			$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
			$path = is_string( $request_uri ) ? wp_parse_url( $request_uri, PHP_URL_PATH ) : null;
			if ( $path !== null && $path !== false && $path !== '' ) {
				$path = trim( (string) $path, '/' );
				$prefix = trim( $this->getUrlPrefix(), '/' );
				if ( $prefix === '' ) {
					$prefix = 'link';
				}
				if ( preg_match( '/^' . preg_quote( $prefix, '/' ) . '\/([a-zA-Z0-9\-]+)\/?$/', $path, $m ) ) {
					$code = $m[1];
				}
			}
		}
		if ( $code === '' || $code === false ) {
			return;
		}

		$link = $this->getLinkByCode( (string) $code );
		if ( $link === null ) {
			$this->maybeRedirectToHome( __( 'This short link does not exist or has been removed.', 'wp-powersuite' ) );
			return;
		}

		if ( empty( $link['active'] ) ) {
			$this->maybeRedirectToHome( __( 'This short link is currently disabled.', 'wp-powersuite' ) );
			return;
		}

		$expires_at = $link['expires_at'] ?? null;
		if ( $expires_at !== null && $expires_at !== '' && strtotime( $expires_at ) < time() ) {
			$this->maybeRedirectExpired( $link );
			return;
		}

		$password_hash = $link['password_hash'] ?? null;
		if ( $password_hash !== null && $password_hash !== '' ) {
			$link_id = (int) ( $link['id'] ?? 0 );
			$cookie_name = 'wpps_short_verified_' . $link_id;
			$expected_token = hash_hmac( 'sha256', (string) $link_id, AUTH_KEY );
			$verified = isset( $_COOKIE[ $cookie_name ] ) && is_string( $_COOKIE[ $cookie_name ] ) && hash_equals( $expected_token, $_COOKIE[ $cookie_name ] );
			if ( ! $verified && isset( $_POST['wpps_short_password'] ) && is_string( $_POST['wpps_short_password'] ) ) {
				$nonce = isset( $_POST['wpps_short_pw_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['wpps_short_pw_nonce'] ) ) : '';
				if ( $nonce === '' || ! wp_verify_nonce( $nonce, 'wpps_short_pw_' . $code ) ) {
					wp_die(
						esc_html__( 'Invalid request.', 'wp-powersuite' ),
						esc_html__( 'Short Link', 'wp-powersuite' ),
						[ 'response' => 403 ]
					);
				}
				if ( $this->isPasswordAttemptRateLimited( $code ) ) {
					wp_die(
						esc_html__( 'Too many password attempts. Please try again later.', 'wp-powersuite' ),
						esc_html__( 'Short Link', 'wp-powersuite' ),
						[ 'response' => 429 ]
					);
				}
				if ( wp_check_password( wp_unslash( $_POST['wpps_short_password'] ), $password_hash, 0 ) ) {
					$verified = true;
					$this->clearPasswordAttemptRateLimit( $code );
					$this->setVerificationCookie( $cookie_name, $expected_token );
				} else {
					$this->recordPasswordAttempt( $code );
				}
			}
			if ( ! $verified ) {
				$this->renderPasswordForm( (string) $code, $link );
				exit;
			}
		}

		$this->queueClick( (int) ( $link['id'] ?? 0 ) );
		$destination = $link['destination_url'] ?? '';
		if ( $destination === '' ) {
			$this->maybeRedirectToHome( __( 'Invalid short link.', 'wp-powersuite' ) );
			return;
		}
		$destination = $this->appendQueryStringToDestination( $destination );
		$redirect_type = $this->normalizeRedirectType( isset( $link['redirect_type'] ) ? (int) $link['redirect_type'] : 301 );
		if ( ! $this->performSafeRedirect( $destination, $redirect_type ) ) {
			$this->maybeRedirectToHome( __( 'Invalid short link.', 'wp-powersuite' ) );
		}
		exit;
	}

	/**
	 * Redirect without falling back to wp-admin for validated external URLs.
	 */
	private function performSafeRedirect( string $url, int $code ): bool {
		$url = esc_url_raw( $url, [ 'http', 'https' ] );
		if ( $url === '' ) {
			return false;
		}

		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! is_string( $host ) || $host === '' ) {
			return false;
		}

		$allow_host = static function ( array $hosts ) use ( $host ): array {
			$hosts[] = $host;

			return array_values( array_unique( $hosts ) );
		};

		add_filter( 'allowed_redirect_hosts', $allow_host );
		$validated = wp_validate_redirect( $url, '' );
		remove_filter( 'allowed_redirect_hosts', $allow_host );

		if ( $validated === '' ) {
			return false;
		}

		add_filter( 'allowed_redirect_hosts', $allow_host );
		wp_safe_redirect( $validated, $code, 'WP PowerSuite Link Shortener' );
		remove_filter( 'allowed_redirect_hosts', $allow_host );

		return true;
	}

	private function appendQueryStringToDestination( string $destination ): string {
		$settings = $this->getSettings();
		if ( empty( $settings['forward_query_strings'] ) ) {
			return $destination;
		}

		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
		if ( ! is_string( $request_uri ) || $request_uri === '' ) {
			return $destination;
		}

		$query = wp_parse_url( $request_uri, PHP_URL_QUERY );
		if ( ! is_string( $query ) || $query === '' ) {
			return $destination;
		}

		parse_str( $query, $params );
		if ( ! is_array( $params ) || $params === [] ) {
			return $destination;
		}

		unset( $params[ self::QUERY_VAR ] );
		$dest_query = wp_parse_url( $destination, PHP_URL_QUERY );
		$dest_params = [];
		if ( is_string( $dest_query ) && $dest_query !== '' ) {
			parse_str( $dest_query, $dest_params );
		}
		if ( ! is_array( $dest_params ) ) {
			$dest_params = [];
		}

		$merged = array_merge( $dest_params, $params );
		$base = strtok( $destination, '?' ) ?: $destination;
		$merged_query = http_build_query( $merged );

		return $merged_query !== '' ? $base . '?' . $merged_query : $base;
	}

	private function normalizeRedirectType( int $type ): int {
		return in_array( $type, [ 301, 302, 307, 308 ], true ) ? $type : 301;
	}

	private function setVerificationCookie( string $name, string $value ): void {
		$secure   = is_ssl();
		$httponly = true;
		$expires  = time() + DAY_IN_SECONDS * 7;

		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				$name,
				$value,
				[
					'expires'  => $expires,
					'path'     => '/',
					'secure'   => $secure,
					'httponly' => $httponly,
					'samesite' => 'Lax',
				]
			);
			return;
		}

		setcookie( $name, $value, $expires, '/', '', $secure, $httponly );
	}

	private function maybeRedirectExpired( array $link ): void {
		$redirect_url = $link['expire_redirect_url'] ?? null;
		if ( $redirect_url !== null && $redirect_url !== '' ) {
			$redirect_url = esc_url_raw( $redirect_url, [ 'http', 'https' ] );
		}
		if ( empty( $redirect_url ) ) {
			$settings = $this->getSettings();
			if ( ! empty( $settings['expired_redirect_home'] ) ) {
				$redirect_url = home_url( '/' );
			}
		}
		if ( ! empty( $redirect_url ) ) {
			if ( ! $this->performSafeRedirect( $redirect_url, 301 ) ) {
				wp_die( esc_html__( 'This short link has expired.', 'wp-powersuite' ), esc_html__( 'Short Link', 'wp-powersuite' ), [ 'response' => 404 ] );
			}
			exit;
		}
		wp_die( esc_html__( 'This short link has expired.', 'wp-powersuite' ), esc_html__( 'Short Link', 'wp-powersuite' ), [ 'response' => 404 ] );
	}

	private function maybeRedirectToHome( string $message ): void {
		$settings = $this->getSettings();
		if ( ! empty( $settings['expired_redirect_home'] ) ) {
			if ( $this->performSafeRedirect( home_url( '/' ), 301 ) ) {
				exit;
			}
		}
		wp_die( esc_html( $message ), esc_html__( 'Short Link', 'wp-powersuite' ), [ 'response' => 404 ] );
	}

	private function renderPasswordForm( string $code, array $link ): void {
		if ( ! headers_sent() ) {
			header( 'X-Robots-Tag: noindex, nofollow', true );
		}
		$prefix = trim( $this->getUrlPrefix(), '/' );
		if ( $prefix === '' ) {
			$prefix = 'link';
		}
		$action = home_url( '/' . $prefix . '/' . $code );
		$wrong = isset( $_POST['wpps_short_password'] );
		?>
		<!DOCTYPE html>
		<html <?php language_attributes(); ?>>
		<head>
			<meta charset="<?php bloginfo( 'charset' ); ?>">
			<meta name="viewport" content="width=device-width, initial-scale=1.0">
			<meta name="robots" content="noindex,nofollow">
			<title><?php esc_html_e( 'Password Required', 'wp-powersuite' ); ?></title>
			<style>
				body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f0f1; margin: 0; padding: 40px 20px; display: flex; align-items: center; justify-content: center; min-height: 100vh; box-sizing: border-box; }
				.wpps-pw-box { background: #fff; padding: 32px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); max-width: 360px; width: 100%; }
				.wpps-pw-box h1 { margin: 0 0 16px; font-size: 1.25rem; }
				.wpps-pw-box form { margin-top: 20px; }
				.wpps-pw-box input[type="password"] { width: 100%; padding: 10px 12px; font-size: 16px; border: 1px solid #8c8f94; border-radius: 4px; box-sizing: border-box; }
				.wpps-pw-box button { margin-top: 12px; width: 100%; padding: 10px; background: #2271b1; color: #fff; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; }
				.wpps-pw-box button:hover { background: #135e96; }
				.wpps-pw-error { color: #d63638; font-size: 13px; margin-top: 8px; }
			</style>
		</head>
		<body>
			<div class="wpps-pw-box">
				<h1><?php esc_html_e( 'Password Required', 'wp-powersuite' ); ?></h1>
				<p><?php esc_html_e( 'This link is protected. Enter the password to continue.', 'wp-powersuite' ); ?></p>
				<form method="post" action="<?php echo esc_url( $action ); ?>">
					<?php wp_nonce_field( 'wpps_short_pw_' . $code, 'wpps_short_pw_nonce' ); ?>
					<input type="password" name="wpps_short_password" placeholder="<?php esc_attr_e( 'Password', 'wp-powersuite' ); ?>" required autofocus />
					<?php if ( $wrong ) : ?>
						<p class="wpps-pw-error"><?php esc_html_e( 'Incorrect password. Please try again.', 'wp-powersuite' ); ?></p>
					<?php endif; ?>
					<button type="submit"><?php esc_html_e( 'Continue', 'wp-powersuite' ); ?></button>
				</form>
			</div>
		</body>
		</html>
		<?php
	}

	public function getUrlPrefix(): string {
		$s = $this->getSettings();
		$prefix = isset( $s['url_prefix'] ) ? trim( (string) $s['url_prefix'] ) : 'link';
		$prefix = self::sanitizeUrlPrefix( $prefix );

		return $prefix !== '' ? $prefix : 'link';
	}

	public static function sanitizeUrlPrefix( string $prefix ): string {
		$prefix = strtolower( trim( $prefix, " \t\n\r\0\x0B/" ) );
		$prefix = preg_replace( '/[^a-z0-9\-]/', '', $prefix ) ?? '';

		if ( $prefix === '' || in_array( $prefix, self::RESERVED_PREFIXES, true ) ) {
			return 'link';
		}

		return $prefix;
	}

	public function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}
		$raw = $this->moduleSettings()->all( [] );
		$defaults = $this->getDefaultSettings();
		$this->settings_cache = is_array( $raw ) ? array_merge( $defaults, $raw ) : $defaults;
		$this->settings_cache['url_prefix'] = self::sanitizeUrlPrefix( (string) ( $this->settings_cache['url_prefix'] ?? 'link' ) );
		$this->settings_cache['default_short_code_length'] = max( 4, min( 20, (int) ( $this->settings_cache['default_short_code_length'] ?? self::DEFAULT_SHORT_CODE_LENGTH ) ) );
		$this->settings_cache['analytics_retention_days'] = max( 0, min( 365, (int) ( $this->settings_cache['analytics_retention_days'] ?? self::DEFAULT_ANALYTICS_RETENTION_DAYS ) ) );

		return $this->settings_cache;
	}

	public function getDefaultSettings(): array {
		return [
			'url_prefix'                => 'link',
			'expired_redirect_home'     => true,
			'default_password'          => '',
			'default_short_code_length' => self::DEFAULT_SHORT_CODE_LENGTH,
			'track_clicks'              => true,
			'track_detailed_analytics'  => true,
			'analytics_retention_days'  => self::DEFAULT_ANALYTICS_RETENTION_DAYS,
			'forward_query_strings'     => false,
		];
	}

	/**
	 * Sanitize module settings before save.
	 *
	 * @param array<string, mixed> $settings Raw settings.
	 * @return array<string, mixed>
	 */
	public static function sanitizeSettings( array $settings ): array {
		$defaults = [
			'url_prefix'                => 'link',
			'expired_redirect_home'     => true,
			'default_password'          => '',
			'default_short_code_length' => self::DEFAULT_SHORT_CODE_LENGTH,
			'track_clicks'              => true,
			'track_detailed_analytics'  => true,
			'analytics_retention_days'  => self::DEFAULT_ANALYTICS_RETENTION_DAYS,
			'forward_query_strings'     => false,
		];
		$settings = array_merge( $defaults, $settings );
		$settings['url_prefix'] = self::sanitizeUrlPrefix( (string) ( $settings['url_prefix'] ?? 'link' ) );
		$settings['default_short_code_length'] = max( 4, min( 20, (int) ( $settings['default_short_code_length'] ?? self::DEFAULT_SHORT_CODE_LENGTH ) ) );
		$settings['analytics_retention_days'] = max( 0, min( 365, (int) ( $settings['analytics_retention_days'] ?? self::DEFAULT_ANALYTICS_RETENTION_DAYS ) ) );
		$settings['default_password'] = sanitize_text_field( (string) ( $settings['default_password'] ?? '' ) );
		$settings['expired_redirect_home'] = ! empty( $settings['expired_redirect_home'] );
		$settings['track_clicks'] = ! empty( $settings['track_clicks'] );
		$settings['track_detailed_analytics'] = ! empty( $settings['track_detailed_analytics'] );
		$settings['forward_query_strings'] = ! empty( $settings['forward_query_strings'] );

		return $settings;
	}

	public function maybeCreateTable(): void {
		if ( ! $this->isEnabled() ) {
			return;
		}
		$this->ensureTableExists();
	}

	private function ensureTableExists(): void {
		global $wpdb;
		$table = $this->getTableName();
		$cache_key = 'wpps_short_links_table_exists';
		$exists = wp_cache_get( $cache_key );
		if ( $exists === false ) {
			$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
			wp_cache_set( $cache_key, $exists, '', 3600 );
		}
		if ( ! $exists ) {
			$this->createTable();
		} else {
			$this->maybeAddExpireRedirectUrlColumn();
			$this->maybeAddRedirectTypeColumn();
			$this->ensureAnalyticsTableExists();
		}
		self::$tables_ready = true;
	}

	private function ensureTableReady(): void {
		if ( self::$tables_ready ) {
			return;
		}

		if ( is_admin() ) {
			$this->ensureTableExists();
			return;
		}

		$cache_key = 'wpps_short_links_table_exists';
		$exists = wp_cache_get( $cache_key );
		if ( $exists === true ) {
			self::$tables_ready = true;
			return;
		}

		global $wpdb;
		$table = $this->getTableName();
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
		if ( $exists ) {
			wp_cache_set( $cache_key, true, '', 3600 );
			self::$tables_ready = true;
		}
	}

	private function createTable(): void {
		global $wpdb;
		$table = $this->getTableName();
		$analytics_table = $this->getAnalyticsTableName();
		$charset = $wpdb->get_charset_collate();
		$sql = "CREATE TABLE IF NOT EXISTS {$table} (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			short_code varchar(191) NOT NULL,
			destination_url text NOT NULL,
			active tinyint(1) NOT NULL DEFAULT 1,
			expires_at datetime DEFAULT NULL,
			expire_redirect_url text DEFAULT NULL,
			password_hash varchar(255) DEFAULT NULL,
			redirect_type smallint(1) NOT NULL DEFAULT 301,
			created_at datetime NOT NULL,
			last_clicked_at datetime DEFAULT NULL,
			click_count bigint(20) NOT NULL DEFAULT 0,
			PRIMARY KEY (id),
			UNIQUE KEY short_code (short_code),
			KEY active (active),
			KEY expires_at (expires_at)
		) {$charset};";
		$analytics_sql = "CREATE TABLE IF NOT EXISTS {$analytics_table} (
			id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			link_id bigint(20) UNSIGNED NOT NULL,
			clicked_at datetime DEFAULT CURRENT_TIMESTAMP,
			ip_address varchar(45) DEFAULT NULL,
			user_agent text DEFAULT NULL,
			referer text DEFAULT NULL,
			PRIMARY KEY (id),
			KEY link_id (link_id),
			KEY clicked_at (clicked_at)
		) {$charset};";
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
		dbDelta( $analytics_sql );
		wp_cache_set( 'wpps_short_links_table_exists', true, '', 3600 );
	}

	private function maybeAddExpireRedirectUrlColumn(): void {
		global $wpdb;
		$table = $this->getTableName();
		$col = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'expire_redirect_url' ) );
		if ( empty( $col ) ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN expire_redirect_url text DEFAULT NULL AFTER expires_at" );
		}
	}

	private function maybeAddRedirectTypeColumn(): void {
		global $wpdb;
		$table = $this->getTableName();
		$col = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'redirect_type' ) );
		if ( empty( $col ) ) {
			$wpdb->query( "ALTER TABLE {$table} ADD COLUMN redirect_type smallint(1) NOT NULL DEFAULT 301 AFTER password_hash" );
		}
	}

	private function ensureAnalyticsTableExists(): void {
		global $wpdb;
		$analytics_table = $this->getAnalyticsTableName();
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $analytics_table ) ) === $analytics_table;
		if ( ! $exists ) {
			$charset = $wpdb->get_charset_collate();
			$sql = "CREATE TABLE IF NOT EXISTS {$analytics_table} (
				id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
				link_id bigint(20) UNSIGNED NOT NULL,
				clicked_at datetime DEFAULT CURRENT_TIMESTAMP,
				ip_address varchar(45) DEFAULT NULL,
				user_agent text DEFAULT NULL,
				referer text DEFAULT NULL,
				PRIMARY KEY (id),
				KEY link_id (link_id),
				KEY clicked_at (clicked_at)
			) {$charset};";
			require_once ABSPATH . 'wp-admin/includes/upgrade.php';
			dbDelta( $sql );
		}
	}

	public static function getAnalyticsTableName(): string {
		global $wpdb;
		return $wpdb->prefix . self::ANALYTICS_TABLE;
	}

	public static function getTableName(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_NAME;
	}

	private function getLinkByCode( string $code ): ?array {
		$code = $this->sanitizeShortCode( $code );
		if ( $code === '' ) {
			return null;
		}
		$this->ensureTableReady();
		if ( ! self::$tables_ready ) {
			return null;
		}
		global $wpdb;
		$table = $this->getTableName();
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, short_code, destination_url, active, expires_at, expire_redirect_url, password_hash, redirect_type, created_at, last_clicked_at, click_count FROM {$table} WHERE short_code = %s LIMIT 1",
				$code
			),
			ARRAY_A
		);
		if ( is_array( $row ) && ! isset( $row['redirect_type'] ) ) {
			$row['redirect_type'] = 301;
		}
		return is_array( $row ) ? $row : null;
	}

	/** Get a single link by ID (for edit form). */
	public function getLink( int $id ): ?array {
		if ( $id <= 0 ) {
			return null;
		}
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, short_code, destination_url, active, expires_at, expire_redirect_url, redirect_type, created_at, last_clicked_at, click_count FROM {$table} WHERE id = %d LIMIT 1",
				$id
			),
			ARRAY_A
		);
		if ( is_array( $row ) && ! isset( $row['redirect_type'] ) ) {
			$row['redirect_type'] = 301;
		}
		return is_array( $row ) ? $row : null;
	}

	private function getClientIp(): string {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		if ( $ip !== '' && filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
			/**
			 * Filter the client IP used for link shortener analytics and rate limiting.
			 *
			 * @since 1.3.0
			 * @param string $ip Detected IP address.
			 */
			return (string) apply_filters( 'wp_powersuite_link_shortener_client_ip', $ip );
		}

		return '';
	}

	private function anonymizeIp( string $ip ): string {
		if ( $ip === '' ) {
			return '';
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			$parts = explode( '.', $ip );
			if ( count( $parts ) === 4 ) {
				$parts[3] = '0';

				return implode( '.', $parts );
			}
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			$parts = explode( ':', $ip );
			$keep  = max( 0, count( $parts ) - 2 );
			for ( $i = $keep; $i < count( $parts ); $i++ ) {
				$parts[ $i ] = '0';
			}

			return implode( ':', $parts );
		}

		return '';
	}

	private function shouldTrackClicks(): bool {
		$settings = $this->getSettings();

		return ! empty( $settings['track_clicks'] );
	}

	private function shouldTrackDetailedAnalytics(): bool {
		$settings = $this->getSettings();

		return $this->shouldTrackClicks() && ! empty( $settings['track_detailed_analytics'] );
	}

	private function isBotRequest(): bool {
		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';
		if ( $user_agent === '' ) {
			return true;
		}

		$bot_patterns = [
			'bot',
			'spider',
			'crawl',
			'slurp',
			'mediapartners',
			'facebookexternalhit',
			'whatsapp',
			'preview',
			'headless',
		];

		foreach ( $bot_patterns as $pattern ) {
			if ( str_contains( $user_agent, $pattern ) ) {
				return true;
			}
		}

		return false;
	}

	private function queueClick( int $id ): void {
		if ( $id <= 0 || ! $this->shouldTrackClicks() || $this->isBotRequest() ) {
			return;
		}

		$this->pending_clicks[] = $id;
		if ( ! has_action( 'shutdown', [ $this, 'processPendingClicks' ] ) ) {
			add_action( 'shutdown', [ $this, 'processPendingClicks' ], 0 );
		}
	}

	public function processPendingClicks(): void {
		if ( $this->pending_clicks === [] ) {
			return;
		}

		foreach ( array_unique( $this->pending_clicks ) as $id ) {
			$this->recordClick( (int) $id );
		}

		$this->pending_clicks = [];
	}

	private function recordClick( int $id ): void {
		if ( $id <= 0 || ! $this->shouldTrackClicks() ) {
			return;
		}

		global $wpdb;
		$table = $this->getTableName();
		$now = current_time( 'mysql' );

		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table} SET click_count = click_count + 1, last_clicked_at = %s WHERE id = %d",
				$now,
				$id
			)
		);

		if ( ! $this->shouldTrackDetailedAnalytics() ) {
			return;
		}

		$this->ensureTableReady();
		if ( ! self::$tables_ready ) {
			return;
		}

		$analytics_table = $this->getAnalyticsTableName();
		$ip = $this->anonymizeIp( $this->getClientIp() );
		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';

		$wpdb->insert(
			$analytics_table,
			[
				'link_id'    => $id,
				'clicked_at' => $now,
				'ip_address' => $ip,
				'user_agent' => $user_agent,
				'referer'    => $referer,
			],
			[ '%d', '%s', '%s', '%s', '%s' ]
		);
	}

	public function pruneAnalytics(): void {
		$settings = $this->getSettings();
		$days = (int) ( $settings['analytics_retention_days'] ?? 0 );
		if ( $days <= 0 ) {
			return;
		}

		$this->ensureTableExists();
		global $wpdb;
		$analytics_table = $this->getAnalyticsTableName();
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$analytics_table} WHERE clicked_at < %s", $cutoff ) );
	}

	public function countLinks( array $args = [] ): int {
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : '';
		$where = '1=1';
		$prepare_args = [];
		if ( $search !== '' ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';
			$where .= ' AND ( short_code LIKE %s OR destination_url LIKE %s )';
			$prepare_args[] = $like;
			$prepare_args[] = $like;
		}
		$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
		if ( $prepare_args !== [] ) {
			$sql = $wpdb->prepare( $sql, $prepare_args );
		}

		return (int) $wpdb->get_var( $sql );
	}

	public function getLinks( array $args = [] ): array {
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$allowed_orderby = [ 'short_code', 'click_count', 'created_at', 'last_clicked_at', 'active' ];
		$orderby = isset( $args['orderby'] ) && in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created_at';
		$order = isset( $args['order'] ) && strtoupper( (string) $args['order'] ) === 'ASC' ? 'ASC' : 'DESC';
		$search = isset( $args['search'] ) ? trim( (string) $args['search'] ) : '';
		$page = max( 1, (int) ( $args['page'] ?? 1 ) );
		$per_page = max( 1, min( 100, (int) ( $args['per_page'] ?? self::DEFAULT_PER_PAGE ) ) );
		$offset = ( $page - 1 ) * $per_page;
		$where = '1=1';
		$prepare_args = [];
		if ( $search !== '' ) {
			$like = '%' . $wpdb->esc_like( $search ) . '%';
			$where .= ' AND ( short_code LIKE %s OR destination_url LIKE %s )';
			$prepare_args[] = $like;
			$prepare_args[] = $like;
		}
		$sql = "SELECT id, short_code, destination_url, active, expires_at, expire_redirect_url, redirect_type, created_at, last_clicked_at, click_count FROM {$table} WHERE {$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
		$prepare_args[] = $per_page;
		$prepare_args[] = $offset;
		$sql = $wpdb->prepare( $sql, $prepare_args );
		$results = $wpdb->get_results( $sql, ARRAY_A );
		if ( is_array( $results ) ) {
			foreach ( $results as &$r ) {
				if ( ! isset( $r['redirect_type'] ) ) {
					$r['redirect_type'] = 301;
				}
			}
		}
		return is_array( $results ) ? $results : [];
	}

	public function getStats(): array {
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$total_links = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
		$total_clicks = (int) $wpdb->get_var( "SELECT COALESCE(SUM(click_count), 0) FROM {$table}" );
		$active_links = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE active = 1" );
		$top_performers = $wpdb->get_results(
			"SELECT id, short_code, destination_url, click_count FROM {$table} ORDER BY click_count DESC LIMIT 5",
			ARRAY_A
		);
		return [
			'total_links'    => $total_links,
			'total_clicks'   => $total_clicks,
			'active_links'   => $active_links,
			'top_performers' => is_array( $top_performers ) ? $top_performers : [],
		];
	}

	/**
	 * Register the Short Links dashboard widget (only when module is enabled).
	 */
	public function addDashboardWidget(): void {
		if ( ! $this->isEnabled() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		wp_add_dashboard_widget(
			'wpps_link_shortener_widget',
			__( 'Short Links', 'wp-powersuite' ),
			[ $this, 'renderDashboardWidget' ],
			null,
			null,
			'side'
		);
	}

	/**
	 * Output the Short Links dashboard widget: description, stats, and manage link.
	 */
	public function renderDashboardWidget(): void {
		$stats = $this->getStats();
		$total_links  = (int) ( $stats['total_links'] ?? 0 );
		$total_clicks = (int) ( $stats['total_clicks'] ?? 0 );
		$active_links = (int) ( $stats['active_links'] ?? 0 );
		$manage_url = admin_url( 'admin.php?page=powersuite' );
		?>
		<p class="description" style="margin-bottom: 12px;">
			<?php esc_html_e( 'Create short URLs, track clicks, and set expiration or password protection.', 'wp-powersuite' ); ?>
		</p>
		<div class="wpps-ls-dashboard-stats" style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 8px;">
			<div class="wpps-ls-dashboard-stat" style="flex: 1; min-width: 70px; background: #f0f6fc; border: 1px solid #c3c4c7; border-radius: 4px; padding: 10px; text-align: center;">
				<span class="wpps-ls-dashboard-value" style="display: block; font-size: 20px; font-weight: 600; color: #1d2327;"><?php echo esc_html( number_format_i18n( $total_links ) ); ?></span>
				<span class="wpps-ls-dashboard-label" style="font-size: 11px; color: #646970;"><?php esc_html_e( 'Total Links', 'wp-powersuite' ); ?></span>
			</div>
			<div class="wpps-ls-dashboard-stat" style="flex: 1; min-width: 70px; background: #f0f6fc; border: 1px solid #c3c4c7; border-radius: 4px; padding: 10px; text-align: center;">
				<span class="wpps-ls-dashboard-value" style="display: block; font-size: 20px; font-weight: 600; color: #1d2327;"><?php echo esc_html( number_format_i18n( $total_clicks ) ); ?></span>
				<span class="wpps-ls-dashboard-label" style="font-size: 11px; color: #646970;"><?php esc_html_e( 'Total Clicks', 'wp-powersuite' ); ?></span>
			</div>
			<div class="wpps-ls-dashboard-stat" style="flex: 1; min-width: 70px; background: #f0f6fc; border: 1px solid #c3c4c7; border-radius: 4px; padding: 10px; text-align: center;">
				<span class="wpps-ls-dashboard-value" style="display: block; font-size: 20px; font-weight: 600; color: #1d2327;"><?php echo esc_html( number_format_i18n( $active_links ) ); ?></span>
				<span class="wpps-ls-dashboard-label" style="font-size: 11px; color: #646970;"><?php esc_html_e( 'Active', 'wp-powersuite' ); ?></span>
			</div>
		</div>
		<p style="margin: 12px 0 0;">
			<a href="<?php echo esc_url( $manage_url ); ?>" onclick="sessionStorage.setItem('wpps_reopen_modal','link-shortener');" class="button button-primary" style="width: 100%; justify-content: center; box-sizing: border-box; text-align: center;">
				<span class="dashicons dashicons-admin-links" style="vertical-align: middle; margin-right: 4px;"></span>
				<?php esc_html_e( 'Manage Short Links', 'wp-powersuite' ); ?>
			</a>
		</p>
		<?php
	}

	public function saveLink( array $data ): array {
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$id = isset( $data['id'] ) ? absint( $data['id'] ) : 0;
		$destination_url = isset( $data['destination_url'] ) ? esc_url_raw( trim( (string) $data['destination_url'] ) ) : '';
		if ( $destination_url === '' ) {
			return [ 'success' => false, 'message' => __( 'Destination URL is required.', 'wp-powersuite' ) ];
		}
		$short_code = $this->sanitizeShortCode( (string) ( $data['short_code'] ?? '' ) );
		if ( $short_code === '' ) {
			$short_code = $this->generateShortCode();
		}
		$expire_redirect_url = isset( $data['expire_redirect_url'] ) ? esc_url_raw( trim( (string) $data['expire_redirect_url'] ) ) : null;
		if ( $expire_redirect_url === '' ) {
			$expire_redirect_url = null;
		}
		if ( strlen( $short_code ) > self::SHORT_CODE_MAX_LENGTH ) {
			return [ 'success' => false, 'message' => __( 'Short code is too long.', 'wp-powersuite' ) ];
		}
		if ( $this->isReservedShortCode( $short_code ) ) {
			return [ 'success' => false, 'message' => __( 'This short code is reserved and cannot be used.', 'wp-powersuite' ) ];
		}
		if ( $this->isRedirectLoop( $short_code, $destination_url ) ) {
			return [ 'success' => false, 'message' => __( 'Destination URL would create a redirect loop.', 'wp-powersuite' ) ];
		}
		$existing = $wpdb->get_row( $wpdb->prepare( "SELECT id FROM {$table} WHERE short_code = %s LIMIT 1", $short_code ) );
		if ( $existing && (int) $existing->id !== $id ) {
			return [ 'success' => false, 'message' => __( 'This short code is already in use.', 'wp-powersuite' ) ];
		}
		$active = ! empty( $data['active'] ) ? 1 : 0;
		$redirect_type = $this->normalizeRedirectType( isset( $data['redirect_type'] ) ? (int) $data['redirect_type'] : 301 );
		$expires_at = null;
		if ( ! empty( $data['expires_at'] ) && is_string( $data['expires_at'] ) ) {
			$ts = strtotime( trim( $data['expires_at'] ) );
			if ( $ts !== false ) {
				$expires_at = gmdate( 'Y-m-d H:i:s', $ts );
			}
		}
		$password_hash = null;
		if ( ! empty( $data['password'] ) && is_string( $data['password'] ) ) {
			$password_hash = wp_hash_password( $data['password'] );
		} elseif ( $id > 0 && ! empty( $data['password_unchanged'] ) ) {
			$row = $wpdb->get_row( $wpdb->prepare( "SELECT password_hash FROM {$table} WHERE id = %d", $id ), ARRAY_A );
			if ( is_array( $row ) && isset( $row['password_hash'] ) && $row['password_hash'] !== '' && $row['password_hash'] !== null ) {
				$password_hash = $row['password_hash'];
			}
		} elseif ( $id === 0 ) {
			$settings = $this->getSettings();
			$default_pw = isset( $settings['default_password'] ) ? trim( (string) $settings['default_password'] ) : '';
			if ( $default_pw !== '' ) {
				$password_hash = wp_hash_password( $default_pw );
			}
		}
		$now = current_time( 'mysql' );
		if ( $id > 0 ) {
			$update_data = [
				'short_code'           => $short_code,
				'destination_url'      => $destination_url,
				'active'               => $active,
				'expires_at'           => $expires_at,
				'expire_redirect_url'  => $expire_redirect_url,
				'password_hash'       => $password_hash,
			];
			$update_format = [ '%s', '%s', '%d', '%s', '%s', '%s' ];
			$has_redirect_type = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'redirect_type' ) );
			if ( ! empty( $has_redirect_type ) ) {
				$update_data['redirect_type'] = $redirect_type;
				$update_format[] = '%d';
			}
			$updated = $wpdb->update( $table, $update_data, [ 'id' => $id ], $update_format, [ '%d' ] );
			if ( $updated === false ) {
				return [ 'success' => false, 'message' => __( 'Failed to update link.', 'wp-powersuite' ) ];
			}
			return [ 'success' => true, 'id' => $id ];
		}
		$insert_data = [
			'short_code'           => $short_code,
			'destination_url'      => $destination_url,
			'active'               => $active,
			'expires_at'           => $expires_at,
			'expire_redirect_url'  => $expire_redirect_url,
			'password_hash'       => $password_hash,
			'created_at'          => $now,
		];
		$insert_format = [ '%s', '%s', '%d', '%s', '%s', '%s', '%s' ];
		$has_redirect_type = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'redirect_type' ) );
		if ( ! empty( $has_redirect_type ) ) {
			$insert_data['redirect_type'] = $redirect_type;
			$insert_format[] = '%d';
		}
		$inserted = $wpdb->insert( $table, $insert_data, $insert_format );
		if ( $inserted === false ) {
			return [ 'success' => false, 'message' => __( 'Failed to create link.', 'wp-powersuite' ) ];
		}
		return [ 'success' => true, 'id' => (int) $wpdb->insert_id ];
	}

	public function deleteLink( int $id ): bool {
		if ( $id <= 0 ) {
			return false;
		}
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$analytics_table = $this->getAnalyticsTableName();
		$wpdb->delete( $analytics_table, [ 'link_id' => $id ], [ '%d' ] );
		$result = $wpdb->delete( $table, [ 'id' => $id ], [ '%d' ] );
		return $result !== false;
	}

	public function toggleActive( int $id ): bool {
		if ( $id <= 0 ) {
			return false;
		}
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT active FROM {$table} WHERE id = %d", $id ), ARRAY_A );
		if ( ! is_array( $row ) || ! isset( $row['active'] ) ) {
			return false;
		}
		$new_active = (int) $row['active'] === 1 ? 0 : 1;
		$wpdb->update( $table, [ 'active' => $new_active ], [ 'id' => $id ], [ '%d' ], [ '%d' ] );
		return true;
	}

	public function getShortUrl( string $code ): string {
		$code = $this->sanitizeShortCode( $code );
		$prefix = trim( $this->getUrlPrefix(), '/' );
		if ( $prefix === '' ) {
			$prefix = 'link';
		}
		return home_url( '/' . $prefix . '/' . $code );
	}

	private function sanitizeShortCode( string $code ): string {
		$code = strtolower( trim( $code ) );
		$code = preg_replace( '/[^a-z0-9\-]/', '', $code );
		return $code === null ? '' : $code;
	}

	private function isReservedShortCode( string $code ): bool {
		return in_array( $code, self::RESERVED_SHORT_CODES, true );
	}

	private function isRedirectLoop( string $short_code, string $destination_url ): bool {
		$short_url = $this->getShortUrl( $short_code );
		$short_path = wp_parse_url( $short_url, PHP_URL_PATH );
		$dest_path = wp_parse_url( $destination_url, PHP_URL_PATH );
		if ( ! is_string( $short_path ) || ! is_string( $dest_path ) ) {
			return false;
		}

		$short_path = untrailingslashit( $short_path );
		$dest_path = untrailingslashit( $dest_path );
		if ( $short_path === $dest_path ) {
			return true;
		}

		$prefix = trim( $this->getUrlPrefix(), '/' );
		$pattern = '#^/' . preg_quote( $prefix, '#' ) . '/([a-z0-9\-]+)/?$#';
		if ( preg_match( $pattern, $dest_path, $matches ) ) {
			$dest_code = $this->sanitizeShortCode( $matches[1] );
			if ( $dest_code === $short_code ) {
				return true;
			}
		}

		return false;
	}

	private static function csvEscape( string $value ): string {
		if ( $value !== '' && in_array( $value[0], [ '=', '+', '-', '@' ], true ) ) {
			return "'" . $value;
		}

		return $value;
	}

	private function generateShortCode(): string {
		global $wpdb;
		$table = $this->getTableName();
		$settings = $this->getSettings();
		$code_len = isset( $settings['default_short_code_length'] ) ? max( 4, min( 20, (int) $settings['default_short_code_length'] ) ) : self::DEFAULT_SHORT_CODE_LENGTH;
		$chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
		$len = strlen( $chars );
		for ( $i = 0; $i < 20; $i++ ) {
			$code = '';
			for ( $j = 0; $j < $code_len; $j++ ) {
				$code .= $chars[ wp_rand( 0, $len - 1 ) ];
			}
			$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE short_code = %s", $code ) );
			if ( ! $exists ) {
				return $code;
			}
		}
		return 'link' . wp_rand( 10000, 99999 );
	}

	/** Truncate URL for display (WP compat: no wp_truncate_words). */
	public static function truncateUrl( string $url, int $max_len = 50 ): string {
		$url = trim( $url );
		if ( $max_len <= 0 || strlen( $url ) <= $max_len ) {
			return $url;
		}
		return substr( $url, 0, $max_len - 3 ) . '...';
	}

	/** Format datetime for datetime-local input (YYYY-MM-DDTHH:MM). */
	public static function formatDateTimeLocal( ?string $mysql_datetime ): string {
		if ( $mysql_datetime === null || $mysql_datetime === '' ) {
			return '';
		}
		$ts = strtotime( $mysql_datetime );
		if ( $ts === false ) {
			return '';
		}
		return gmdate( 'Y-m-d\TH:i', $ts );
	}

	// ----- AJAX handlers -----

	public function ajaxListLinks(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$search = isset( $_REQUEST['search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['search'] ) ) : '';
		$orderby = isset( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : 'created_at';
		$order = isset( $_REQUEST['order'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) : 'DESC';
		$page = isset( $_REQUEST['page'] ) ? max( 1, absint( $_REQUEST['page'] ) ) : 1;
		$per_page = isset( $_REQUEST['per_page'] ) ? max( 1, min( 100, absint( $_REQUEST['per_page'] ) ) ) : self::DEFAULT_PER_PAGE;
		$list_args = [
			'search'   => $search,
			'orderby'  => $orderby,
			'order'    => $order,
			'page'     => $page,
			'per_page' => $per_page,
		];
		$links = $this->getLinks( $list_args );
		$total = $this->countLinks( [ 'search' => $search ] );
		$stats = $this->getStats();
		wp_send_json_success(
			[
				'links'      => $links,
				'stats'      => $stats,
				'pagination' => [
					'page'        => $page,
					'per_page'    => $per_page,
					'total'       => $total,
					'total_pages' => (int) ceil( $total / $per_page ),
				],
			]
		);
	}

	public function ajaxGetLink(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
		$link = $id > 0 ? $this->getLink( $id ) : null;
		if ( $link === null ) {
			wp_send_json_error( [ 'message' => __( 'Link not found.', 'wp-powersuite' ) ] );
		}
		$link['short_url'] = $this->getShortUrl( $link['short_code'] ?? '' );
		$link['expires_at_local'] = self::formatDateTimeLocal( $link['expires_at'] ?? null );
		wp_send_json_success( [ 'link' => $link ] );
	}

	public function getAnalytics( int $link_id, int $limit = 50, int $offset = 0 ): array {
		if ( $link_id <= 0 ) {
			return [];
		}
		$this->ensureTableExists();
		global $wpdb;
		$analytics_table = $this->getAnalyticsTableName();
		$limit = max( 1, min( 100, $limit ) );
		$offset = max( 0, $offset );
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, link_id, clicked_at, ip_address, user_agent, referer FROM {$analytics_table} WHERE link_id = %d ORDER BY clicked_at DESC LIMIT %d OFFSET %d",
				$link_id,
				$limit,
				$offset
			),
			ARRAY_A
		);
		return is_array( $results ) ? $results : [];
	}

	public function countAnalytics( int $link_id ): int {
		if ( $link_id <= 0 ) {
			return 0;
		}
		$this->ensureTableExists();
		global $wpdb;
		$analytics_table = $this->getAnalyticsTableName();

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$analytics_table} WHERE link_id = %d",
				$link_id
			)
		);
	}

	public function ajaxGetAnalytics(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : ( isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0 );
		$link = $id > 0 ? $this->getLink( $id ) : null;
		if ( $link === null ) {
			wp_send_json_error( [ 'message' => __( 'Link not found.', 'wp-powersuite' ) ] );
		}
		$page = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : ( isset( $_GET['page'] ) ? max( 1, absint( $_GET['page'] ) ) : 1 );
		$per_page = isset( $_POST['per_page'] ) ? max( 1, min( 100, absint( $_POST['per_page'] ) ) ) : 50;
		$offset = ( $page - 1 ) * $per_page;
		$analytics = $this->getAnalytics( $id, $per_page, $offset );
		$total = $this->countAnalytics( $id );
		$short_url = $this->getShortUrl( $link['short_code'] ?? '' );
		wp_send_json_success( [
			'link'      => $link,
			'short_url' => $short_url,
			'analytics' => $analytics,
			'pagination' => [
				'page'        => $page,
				'per_page'    => $per_page,
				'total'       => $total,
				'total_pages' => (int) ceil( $total / $per_page ),
			],
		] );
	}

	public function ajaxExportCsv(): void {
		$nonce = isset( $_REQUEST['nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'wp_powersuite_ajax' ) || ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ), '', [ 'response' => 403 ] );
		}
		$search = isset( $_REQUEST['search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['search'] ) ) : '';
		$links = $this->getLinks( [ 'search' => $search, 'orderby' => 'created_at', 'order' => 'DESC', 'page' => 1, 'per_page' => 10000 ] );
		$prefix = trim( $this->getUrlPrefix(), '/' );
		if ( $prefix === '' ) {
			$prefix = 'link';
		}
		$filename = 'short-links-' . gmdate( 'Y-m-d' ) . '.csv';
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		$out = fopen( 'php://output', 'w' );
		if ( $out === false ) {
			wp_send_json_error( [ 'message' => __( 'Export failed.', 'wp-powersuite' ) ] );
		}
		fputcsv( $out, [ 'Short Code', 'Short URL', 'Destination URL', 'Redirect Type', 'Clicks', 'Active', 'Expires', 'Expire Redirect', 'Created', 'Last Clicked' ] );
		foreach ( $links as $link ) {
			$short_url = home_url( '/' . $prefix . '/' . ( $link['short_code'] ?? '' ) );
			$expires_ts = ! empty( $link['expires_at'] ) ? strtotime( $link['expires_at'] ) : false;
			$expired = $expires_ts && $expires_ts < time();
			$active = ! empty( $link['active'] ) && ! $expired ? 'Yes' : 'No';
			$redirect_type = isset( $link['redirect_type'] ) ? (string) (int) $link['redirect_type'] : '301';
			fputcsv( $out, [
				self::csvEscape( (string) ( $link['short_code'] ?? '' ) ),
				self::csvEscape( $short_url ),
				self::csvEscape( (string) ( $link['destination_url'] ?? '' ) ),
				$redirect_type,
				$link['click_count'] ?? 0,
				$active,
				$link['expires_at'] ?? '',
				$link['expire_redirect_url'] ?? '',
				$link['created_at'] ?? '',
				$link['last_clicked_at'] ?? '',
			] );
		}
		fclose( $out );
		exit;
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	private function getAnalyticsExportRows( ?int $link_id = null ): array {
		$this->ensureTableExists();
		global $wpdb;
		$analytics_table = $this->getAnalyticsTableName();
		$links_table = $this->getTableName();
		$max_rows = self::ANALYTICS_EXPORT_MAX_ROWS;

		if ( $link_id !== null && $link_id > 0 ) {
			$results = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT a.clicked_at, a.ip_address, a.user_agent, a.referer, l.short_code, l.destination_url
					FROM {$analytics_table} a
					INNER JOIN {$links_table} l ON l.id = a.link_id
					WHERE a.link_id = %d
					ORDER BY a.clicked_at DESC
					LIMIT %d",
					$link_id,
					$max_rows
				),
				ARRAY_A
			);
		} else {
			$results = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT a.clicked_at, a.ip_address, a.user_agent, a.referer, l.short_code, l.destination_url
					FROM {$analytics_table} a
					INNER JOIN {$links_table} l ON l.id = a.link_id
					ORDER BY a.clicked_at DESC
					LIMIT %d",
					$max_rows
				),
				ARRAY_A
			);
		}

		return is_array( $results ) ? $results : [];
	}

	public function ajaxExportAnalyticsCsv(): void {
		$nonce = isset( $_REQUEST['nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'wp_powersuite_ajax' ) || ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'wp-powersuite' ), '', [ 'response' => 403 ] );
		}

		$link_id = isset( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0;
		if ( $link_id > 0 ) {
			$link = $this->getLink( $link_id );
			if ( $link === null ) {
				wp_die( esc_html__( 'Link not found.', 'wp-powersuite' ), '', [ 'response' => 404 ] );
			}
		}

		$rows = $this->getAnalyticsExportRows( $link_id > 0 ? $link_id : null );
		$filename = $link_id > 0
			? 'short-link-analytics-' . sanitize_file_name( (string) ( $link['short_code'] ?? (string) $link_id ) ) . '-' . gmdate( 'Y-m-d' ) . '.csv'
			: 'short-link-analytics-all-' . gmdate( 'Y-m-d' ) . '.csv';

		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		$out = fopen( 'php://output', 'w' );
		if ( $out === false ) {
			wp_die( esc_html__( 'Export failed.', 'wp-powersuite' ), '', [ 'response' => 500 ] );
		}

		fputcsv( $out, [ 'Short Code', 'Destination URL', 'Date / Time', 'IP Address', 'Referer', 'User Agent' ] );
		foreach ( $rows as $row ) {
			fputcsv( $out, [
				self::csvEscape( (string) ( $row['short_code'] ?? '' ) ),
				self::csvEscape( (string) ( $row['destination_url'] ?? '' ) ),
				$row['clicked_at'] ?? '',
				self::csvEscape( (string) ( $row['ip_address'] ?? '' ) ),
				self::csvEscape( (string) ( $row['referer'] ?? '' ) ),
				self::csvEscape( (string) ( $row['user_agent'] ?? '' ) ),
			] );
		}
		fclose( $out );
		exit;
	}

	/**
	 * Import links from CSV. Accepts: Short Code, Destination URL (required), optionally Active, Expires, Expire Redirect, Redirect Type.
	 * Also accepts export format: Short Code, Short URL, Destination URL, Redirect Type, Clicks, Active, Expires, Expire Redirect, Created, Last Clicked.
	 *
	 * @param string $csv_raw Raw CSV content.
	 * @return array{imported: int, skipped: int, errors: array}
	 */
	public function importLinksFromCsv( string $csv_raw ): array {
		$this->ensureTableExists();
		$imported = 0;
		$skipped = 0;
		$errors = [];
		$lines = preg_split( '/\r\n|\r|\n/', trim( $csv_raw ) );
		if ( empty( $lines ) ) {
			return [ 'imported' => 0, 'skipped' => 0, 'errors' => [ __( 'No data in CSV.', 'wp-powersuite' ) ] ];
		}
		$header = str_getcsv( array_shift( $lines ) );
		$header = array_map( 'trim', array_map( 'strtolower', $header ) );
		$col_map = [];
		$aliases = [
			'short code' => 'short_code',
			'shortcode' => 'short_code',
			'destination url' => 'destination_url',
			'destination' => 'destination_url',
			'url' => 'destination_url',
			'redirect type' => 'redirect_type',
			'active' => 'active',
			'expires' => 'expires_at',
			'expire redirect' => 'expire_redirect_url',
		];
		foreach ( $header as $i => $h ) {
			$col_map[ $aliases[ $h ] ?? $h ] = $i;
		}
		$dest_idx = $col_map['destination_url'] ?? null;
		if ( $dest_idx === null ) {
			foreach ( [ 'destination url', 'destination_url', 'destination', 'url' ] as $k ) {
				$idx = array_search( $k, $header, true );
				if ( $idx !== false ) {
					$dest_idx = $idx;
					break;
				}
			}
		}
		if ( $dest_idx === null || $dest_idx === false ) {
			return [ 'imported' => 0, 'skipped' => 0, 'errors' => [ __( 'CSV must have a Destination URL column.', 'wp-powersuite' ) ] ];
		}
		$short_idx = $col_map['short_code'] ?? null;
		if ( $short_idx === null ) {
			$idx = array_search( 'short code', $header, true );
			$short_idx = $idx !== false ? $idx : null;
		}
		$active_idx = $col_map['active'] ?? array_search( 'active', $header, true );
		$expires_idx = $col_map['expires_at'] ?? array_search( 'expires', $header, true );
		$expire_redirect_idx = $col_map['expire_redirect_url'] ?? array_search( 'expire redirect', $header, true );
		$redirect_type_idx = $col_map['redirect_type'] ?? array_search( 'redirect type', $header, true );

		foreach ( $lines as $row_num => $line ) {
			$row = str_getcsv( $line );
			if ( empty( $row ) ) {
				continue;
			}
			$dest = isset( $row[ $dest_idx ] ) ? trim( (string) $row[ $dest_idx ] ) : '';
			if ( $dest === '' || ! filter_var( $dest, FILTER_VALIDATE_URL ) ) {
				$skipped++;
				continue;
			}
			$short_code = ( $short_idx !== null && isset( $row[ $short_idx ] ) ) ? trim( (string) $row[ $short_idx ] ) : '';
			$short_code = $this->sanitizeShortCode( $short_code );
			if ( $short_code === '' ) {
				$short_code = $this->generateShortCode();
			}
			$active = 1;
			if ( $active_idx !== false && isset( $row[ $active_idx ] ) ) {
				$v = strtolower( trim( (string) $row[ $active_idx ] ) );
				$active = in_array( $v, [ '1', 'yes', 'true', 'y' ], true ) ? 1 : 0;
			}
			$expires_at = null;
			if ( $expires_idx !== false && isset( $row[ $expires_idx ] ) && trim( (string) $row[ $expires_idx ] ) !== '' ) {
				$ts = strtotime( trim( (string) $row[ $expires_idx ] ) );
				if ( $ts !== false ) {
					$expires_at = gmdate( 'Y-m-d H:i:s', $ts );
				}
			}
			$expire_redirect_url = null;
			if ( $expire_redirect_idx !== false && isset( $row[ $expire_redirect_idx ] ) ) {
				$u = esc_url_raw( trim( (string) $row[ $expire_redirect_idx ] ) );
				$expire_redirect_url = $u !== '' ? $u : null;
			}
			$redirect_type = 301;
			if ( $redirect_type_idx !== false && isset( $row[ $redirect_type_idx ] ) ) {
				$redirect_type = $this->normalizeRedirectType( (int) $row[ $redirect_type_idx ] );
			}
			$result = $this->saveLink( [
				'short_code'          => $short_code,
				'destination_url'     => $dest,
				'active'              => (bool) $active,
				'expires_at'          => $expires_at,
				'expire_redirect_url' => $expire_redirect_url,
				'redirect_type'       => $redirect_type,
			] );
			if ( ! empty( $result['success'] ) ) {
				$imported++;
			} else {
				$errors[] = sprintf( __( 'Row %d: %s', 'wp-powersuite' ), $row_num + 2, $result['message'] ?? __( 'Unknown error', 'wp-powersuite' ) );
			}
		}
		return [ 'imported' => $imported, 'skipped' => $skipped, 'errors' => $errors ];
	}

	public function ajaxImportCsv(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$csv = isset( $_POST['csv'] ) ? wp_unslash( $_POST['csv'] ) : '';
		if ( ! is_string( $csv ) || $csv === '' ) {
			wp_send_json_error( [ 'message' => __( 'No CSV data provided.', 'wp-powersuite' ) ] );
		}
		$result = $this->importLinksFromCsv( $csv );
		wp_send_json_success( [
			'imported' => $result['imported'],
			'skipped'  => $result['skipped'],
			'errors'   => $result['errors'],
			'message'  => sprintf(
				/* translators: 1: number imported, 2: number skipped */
				__( 'Imported %1$d links. Skipped %2$d rows.', 'wp-powersuite' ),
				$result['imported'],
				$result['skipped']
			),
		] );
	}

	public function duplicateLink( int $id ): ?array {
		$link = $this->getLink( $id );
		if ( $link === null ) {
			return null;
		}
		$base_code = $link['short_code'] ?? '';
		$new_code = $base_code . '-copy';
		$attempts = 0;
		while ( $attempts < 20 ) {
			$existing = $this->getLinkByCode( $new_code );
			if ( $existing === null ) {
				break;
			}
			$new_code = $base_code . '-copy-' . ( $attempts + 1 );
			$attempts++;
		}
		$result = $this->saveLink( [
			'short_code'          => $new_code,
			'destination_url'     => $link['destination_url'] ?? '',
			'active'              => false,
			'expires_at'          => $link['expires_at'] ?? null,
			'expire_redirect_url' => $link['expire_redirect_url'] ?? null,
			'redirect_type'       => isset( $link['redirect_type'] ) ? $this->normalizeRedirectType( (int) $link['redirect_type'] ) : 301,
		] );
		if ( ! empty( $result['success'] ) && ! empty( $result['id'] ) ) {
			return $this->getLink( (int) $result['id'] );
		}
		return null;
	}

	public function ajaxDuplicateLink(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		$new_link = $id > 0 ? $this->duplicateLink( $id ) : null;
		if ( $new_link !== null ) {
			$short_url = $this->getShortUrl( $new_link['short_code'] ?? '' );
			wp_send_json_success( [ 'link' => $new_link, 'short_url' => $short_url ] );
		}
		wp_send_json_error( [ 'message' => __( 'Failed to duplicate link.', 'wp-powersuite' ) ] );
	}

	public function ajaxSaveLink(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$data = [
			'id'                  => isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0,
			'short_code'          => isset( $_POST['short_code'] ) ? sanitize_text_field( wp_unslash( $_POST['short_code'] ) ) : '',
			'destination_url'     => isset( $_POST['destination_url'] ) ? esc_url_raw( wp_unslash( $_POST['destination_url'] ) ) : '',
			'active'              => isset( $_POST['active'] ) && $_POST['active'] === '1',
			'expires_at'          => isset( $_POST['expires_at'] ) ? sanitize_text_field( wp_unslash( $_POST['expires_at'] ) ) : '',
			'expire_redirect_url' => isset( $_POST['expire_redirect_url'] ) ? esc_url_raw( wp_unslash( $_POST['expire_redirect_url'] ) ) : '',
			'password'            => isset( $_POST['password'] ) ? $_POST['password'] : '',
			'password_unchanged'   => isset( $_POST['password_unchanged'] ) && $_POST['password_unchanged'] === '1',
			'redirect_type'       => isset( $_POST['redirect_type'] ) ? $this->normalizeRedirectType( (int) $_POST['redirect_type'] ) : 301,
		];
		$result = $this->saveLink( $data );
		if ( ! empty( $result['success'] ) ) {
			$id = (int) ( $result['id'] ?? 0 );
			$short_url = '';
			if ( $id > 0 ) {
				$row = $this->getLink( $id );
				if ( $row !== null && ! empty( $row['short_code'] ) ) {
					$short_url = $this->getShortUrl( $row['short_code'] );
				}
			}
			wp_send_json_success( [ 'id' => $id, 'short_url' => $short_url ] );
		}
		wp_send_json_error( [ 'message' => $result['message'] ?? __( 'Failed to save link.', 'wp-powersuite' ) ] );
	}

	public function ajaxDeleteLink(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		if ( $id > 0 && $this->deleteLink( $id ) ) {
			wp_send_json_success();
		}
		wp_send_json_error( [ 'message' => __( 'Failed to delete link.', 'wp-powersuite' ) ] );
	}

	public function ajaxBulkAction(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$action = isset( $_POST['bulk_action'] ) ? sanitize_text_field( wp_unslash( $_POST['bulk_action'] ) ) : '';
		$ids_raw = isset( $_POST['ids'] ) ? $_POST['ids'] : null;
		$ids = [];
		if ( is_array( $ids_raw ) ) {
			$ids = array_map( 'absint', wp_unslash( $ids_raw ) );
		} elseif ( is_string( $ids_raw ) || is_numeric( $ids_raw ) ) {
			$ids = [ absint( $ids_raw ) ];
		}
		$ids = array_filter( $ids );
		if ( $ids === [] || ! in_array( $action, [ 'activate', 'deactivate', 'delete' ], true ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$done = 0;
		foreach ( $ids as $id ) {
			if ( $action === 'delete' ) {
				if ( $this->deleteLink( $id ) ) {
					$done++;
				}
			} elseif ( $action === 'activate' || $action === 'deactivate' ) {
				$wpdb->update( $table, [ 'active' => $action === 'activate' ? 1 : 0 ], [ 'id' => $id ], [ '%d' ], [ '%d' ] );
				$done++;
			}
		}
		wp_send_json_success( [ 'updated' => $done ] );
	}

	public function ajaxToggleActive(): void {
		check_ajax_referer( 'wp_powersuite_ajax', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		if ( $id > 0 && $this->toggleActive( $id ) ) {
			wp_send_json_success();
		}
		wp_send_json_error( [ 'message' => __( 'Failed to toggle.', 'wp-powersuite' ) ] );
	}

	public function ajaxVerifyPassword(): void {
		$code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : '';
		if ( $code === '' ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}

		check_ajax_referer( 'wpps_link_shortener_pw_' . $code, 'nonce' );

		if ( $this->isPasswordAttemptRateLimited( $code ) ) {
			wp_send_json_error(
				[ 'message' => __( 'Too many attempts. Please try again later.', 'wp-powersuite' ) ],
				429
			);
		}

		$password = isset( $_POST['password'] ) && is_string( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : '';
		$link = $this->getLinkByCode( $code );
		if ( $link === null || empty( $link['password_hash'] ) ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}
		if ( is_string( $password ) && wp_check_password( $password, $link['password_hash'], 0 ) ) {
			$this->clearPasswordAttemptRateLimit( $code );
			$link_id = (int) ( $link['id'] ?? 0 );
			$cookie_name = 'wpps_short_verified_' . $link_id;
			$expected_token = hash_hmac( 'sha256', (string) $link_id, AUTH_KEY );
			$this->setVerificationCookie( $cookie_name, $expected_token );
			wp_send_json_success();
		}

		$this->recordPasswordAttempt( $code );
		wp_send_json_error( [ 'message' => __( 'Incorrect password.', 'wp-powersuite' ) ] );
	}

	private function getPasswordAttemptTransientKey( string $code ): string {
		$code = sanitize_key( $code );
		$ip   = $this->getClientIp();
		if ( $ip === '' ) {
			$ip = 'unknown';
		}

		/**
		 * Filter the client IP used for link shortener password rate limiting.
		 *
		 * @since 1.0.0
		 * @param string $ip   Detected IP address.
		 * @param string $code Short link code.
		 */
		$ip = (string) apply_filters( 'wp_powersuite_link_shortener_password_rate_limit_ip', $ip, $code );

		return 'wpps_ls_pw_' . md5( $code . '|' . $ip );
	}

	private function isPasswordAttemptRateLimited( string $code ): bool {
		$key      = $this->getPasswordAttemptTransientKey( $code );
		$attempts = (int) get_transient( $key );

		return $attempts >= self::PASSWORD_ATTEMPT_LIMIT;
	}

	private function recordPasswordAttempt( string $code ): void {
		$key      = $this->getPasswordAttemptTransientKey( $code );
		$attempts = (int) get_transient( $key );
		set_transient( $key, $attempts + 1, self::PASSWORD_ATTEMPT_WINDOW );
	}

	private function clearPasswordAttemptRateLimit( string $code ): void {
		delete_transient( $this->getPasswordAttemptTransientKey( $code ) );
	}

	// ----- Classic editor -----

	public function addClassicEditorMetaBox(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$post_types = get_post_types( [ 'public' => true ], 'names' );
		foreach ( (array) $post_types as $post_type ) {
			add_meta_box(
				'wpps_short_link',
				__( 'Short Link', 'wp-powersuite' ),
				[ $this, 'renderClassicEditorMetaBox' ],
				$post_type,
				'side'
			);
		}
	}

	public function renderClassicEditorMetaBox( \WP_Post $post ): void {
		$url = get_permalink( $post );
		echo '<div id="wpps-short-link-mb" class="wpps-short-link-meta-box">';
		if ( ! $url || ! is_string( $url ) ) {
			echo '<p class="wpps-sl-mb-notice">' . esc_html__( 'Publish or update the post to get a permalink, then you can create a short link.', 'wp-powersuite' ) . '</p>';
			echo '</div>';
			return;
		}
		$existing = $this->getLinkByDestination( $url );
		if ( $existing !== null ) {
			$short_url = $this->getShortUrl( $existing['short_code'] ?? '' );
			$clicks = (int) ( $existing['click_count'] ?? 0 );
			echo '<p class="wpps-sl-mb-label">' . esc_html__( 'Short link', 'wp-powersuite' ) . '</p>';
			echo '<div class="wpps-sl-mb-input-wrap">';
			echo '<input type="text" readonly class="large-text code" value="' . esc_attr( $short_url ) . '" id="wpps-short-link-url" />';
			echo '</div>';
			echo '<p class="wpps-sl-mb-actions">';
			echo '<button type="button" class="button wpps-copy-short-link">' . esc_html__( 'Copy', 'wp-powersuite' ) . '</button> ';
			echo '<a href="' . esc_url( $short_url ) . '" target="_blank" rel="noopener" class="button">' . esc_html__( 'Open', 'wp-powersuite' ) . '</a>';
			echo '</p>';
			if ( $clicks > 0 ) {
				echo '<p class="wpps-sl-mb-clicks description">' . esc_html( sprintf( _n( '%s click', '%s clicks', $clicks, 'wp-powersuite' ), number_format_i18n( $clicks ) ) ) . '</p>';
			}
			echo '</div>';
			return;
		}
		echo '<p class="wpps-sl-mb-notice">' . esc_html__( 'Create a memorable short link for this post to share or track clicks.', 'wp-powersuite' ) . '</p>';
		echo '<p><button type="button" class="button button-primary wpps-create-short-link" data-url="' . esc_attr( $url ) . '">' . esc_html__( 'Create Short Link', 'wp-powersuite' ) . '</button></p>';
		echo '</div>';
	}

	/**
	 * Add Short Link row action to Posts and Pages list tables.
	 *
	 * @param array   $actions Row actions.
	 * @param WP_Post $post    Post object.
	 * @return array Modified actions.
	 */
	public function addPostRowActions( array $actions, \WP_Post $post ): array {
		if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'edit_post', $post->ID ) ) {
			return $actions;
		}
		$url = get_permalink( $post );
		if ( ! $url || ! is_string( $url ) ) {
			return $actions;
		}
		$existing = $this->getLinkByDestination( $url );
		if ( $existing !== null ) {
			$short_url = $this->getShortUrl( $existing['short_code'] ?? '' );
			$actions['wpps_short_link'] = '<a href="#" class="wpps-row-copy-short-link" data-short-url="' . esc_attr( $short_url ) . '" aria-label="' . esc_attr__( 'Copy short link', 'wp-powersuite' ) . '">' . esc_html__( 'Copy Short Link', 'wp-powersuite' ) . '</a>';
		} else {
			$actions['wpps_short_link'] = '<a href="#" class="wpps-row-create-short-link" data-url="' . esc_attr( $url ) . '" aria-label="' . esc_attr__( 'Create short link', 'wp-powersuite' ) . '">' . esc_html__( 'Create Short Link', 'wp-powersuite' ) . '</a>';
		}
		return $actions;
	}

	private function getLinkByDestination( string $url ): ?array {
		$this->ensureTableExists();
		global $wpdb;
		$table = $this->getTableName();
		$normalized = trailingslashit( $url );
		$row = $wpdb->get_row( $wpdb->prepare(
			"SELECT id, short_code, destination_url, click_count FROM {$table} WHERE destination_url = %s OR destination_url = %s LIMIT 1",
			$url,
			$normalized
		), ARRAY_A );
		return is_array( $row ) ? $row : null;
	}

	public function enqueueBlockEditorAssets(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$asset_file = __DIR__ . '/assets/link-shortener-editor.js';
		if ( ! file_exists( $asset_file ) || ! is_readable( $asset_file ) ) {
			return;
		}
		wp_enqueue_script(
			'wpps-link-shortener-editor',
			plugins_url( 'assets/link-shortener-editor.js', __FILE__ ),
			[ 'wp-element', 'wp-components', 'wp-data', 'wp-plugins', 'wp-edit-post' ],
			(string) filemtime( $asset_file ),
			true
		);
		wp_localize_script( 'wpps-link-shortener-editor', 'wppsLinkShortener', [
			'createShortLink' => __( 'Create Short Link', 'wp-powersuite' ),
			'copyShortLink'   => __( 'Copy Short Link', 'wp-powersuite' ),
			'ajaxUrl'         => admin_url( 'admin-ajax.php' ),
			'nonce'           => wp_create_nonce( 'wp_powersuite_ajax' ),
		] );
	}

	public function maybeShortLinkMetaBoxStyles(): void {
		?>
		<style>
		.wpps-short-link-meta-box .wpps-sl-mb-label { margin: 0 0 6px; font-weight: 600; font-size: 12px; }
		.wpps-short-link-meta-box .wpps-sl-mb-input-wrap { margin-bottom: 8px; }
		.wpps-short-link-meta-box .wpps-sl-mb-input-wrap input { font-size: 12px; }
		.wpps-short-link-meta-box .wpps-sl-mb-actions { margin: 0 0 4px; }
		.wpps-short-link-meta-box .wpps-sl-mb-actions .button { margin-right: 4px; }
		.wpps-short-link-meta-box .wpps-sl-mb-clicks { margin: 0; font-size: 12px; }
		.wpps-short-link-meta-box .wpps-sl-mb-notice { margin: 0 0 10px; color: #646970; font-size: 13px; }
		</style>
		<?php
	}

	public function maybeInlineRowActionsScript(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || $screen->base !== 'edit' ) {
			return;
		}
		$copied_text     = esc_js( __( 'Copied!', 'wp-powersuite' ) );
		$copy_fail       = esc_js( __( 'Copy failed.', 'wp-powersuite' ) );
		$original_text   = esc_js( __( 'Copy Short Link', 'wp-powersuite' ) );
		$create_text     = esc_js( __( 'Create Short Link', 'wp-powersuite' ) );
		$creating_text   = esc_js( __( 'Creating…', 'wp-powersuite' ) );
		$created_text    = esc_js( __( 'Short link created.', 'wp-powersuite' ) );
		$failed_text     = esc_js( __( 'Failed to create short link.', 'wp-powersuite' ) );
		$ajax_url        = esc_url( admin_url( 'admin-ajax.php' ) );
		$nonce           = esc_js( wp_create_nonce( 'wp_powersuite_ajax' ) );
		$copy_short_aria = esc_attr__( 'Copy short link', 'wp-powersuite' );
		?>
		<script>
		(function() {
			var ajaxUrl = '<?php echo $ajax_url; ?>';
			var nonce = '<?php echo $nonce; ?>';
			var copyShortLinkLabel = '<?php echo $original_text; ?>';
			var createdMsg = '<?php echo $created_text; ?>';
			var failedMsg = '<?php echo $failed_text; ?>';
			var creatingLabel = '<?php echo $creating_text; ?>';

			document.body.addEventListener('click', function(e) {
				var link = e.target.closest('.wpps-row-copy-short-link');
				if (link) {
					e.preventDefault();
					var url = link.getAttribute('data-short-url');
					if (!url) return;
					function revert() { link.textContent = copyShortLinkLabel; }
					if (navigator.clipboard && navigator.clipboard.writeText) {
						navigator.clipboard.writeText(url).then(function() {
							link.textContent = '<?php echo $copied_text; ?>';
							setTimeout(revert, 2000);
						}).catch(function() { alert('<?php echo $copy_fail; ?>'); });
					} else {
						var inp = document.createElement('input');
						inp.value = url;
						document.body.appendChild(inp);
						inp.select();
						try {
							document.execCommand('copy');
							link.textContent = '<?php echo $copied_text; ?>';
							setTimeout(revert, 2000);
						} catch (err) { alert('<?php echo $copy_fail; ?>'); }
						document.body.removeChild(inp);
					}
					return;
				}

				link = e.target.closest('.wpps-row-create-short-link');
				if (!link) return;
				e.preventDefault();
				var destUrl = link.getAttribute('data-url');
				if (!destUrl) return;
				var origText = link.textContent;
				link.textContent = creatingLabel;
				link.style.pointerEvents = 'none';
				var fd = new FormData();
				fd.append('action', 'wpps_link_shortener_save');
				fd.append('nonce', nonce);
				fd.append('destination_url', destUrl);
				fd.append('short_code', '');
				fd.append('active', '1');
				fetch(ajaxUrl, { method: 'POST', body: fd })
					.then(function(r) { return r.json(); })
					.then(function(data) {
						link.style.pointerEvents = '';
						if (data.success && data.data && data.data.short_url) {
							link.removeAttribute('data-url');
							link.setAttribute('data-short-url', data.data.short_url);
							link.setAttribute('aria-label', '<?php echo $copy_short_aria; ?>');
							link.classList.remove('wpps-row-create-short-link');
							link.classList.add('wpps-row-copy-short-link');
							link.textContent = copyShortLinkLabel;
							if (navigator.clipboard && navigator.clipboard.writeText) {
								navigator.clipboard.writeText(data.data.short_url);
							}
							link.textContent = '<?php echo $copied_text; ?>';
							setTimeout(function() { link.textContent = copyShortLinkLabel; }, 2000);
						} else {
							link.textContent = origText;
							alert(data.data && data.data.message ? data.data.message : failedMsg);
						}
					})
					.catch(function() {
						link.style.pointerEvents = '';
						link.textContent = origText;
						alert(failedMsg);
					});
			});
		})();
		</script>
		<?php
	}

	public function maybeInlineShortLinkScript(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || $screen->base !== 'post' ) {
			return;
		}
		$ajax_url = esc_url( admin_url( 'admin-ajax.php' ) );
		$nonce = esc_js( wp_create_nonce( 'wp_powersuite_ajax' ) );
		$copied = esc_js( __( 'Copied!', 'wp-powersuite' ) );
		$copy_label = esc_js( __( 'Copy', 'wp-powersuite' ) );
		$failed = esc_js( __( 'Failed to create short link.', 'wp-powersuite' ) );
		?>
		<script>
		(function() {
			document.addEventListener('DOMContentLoaded', function() {
				document.querySelectorAll('.wpps-copy-short-link').forEach(function(btn) {
					btn.addEventListener('click', function() {
						var input = document.getElementById('wpps-short-link-url');
						if (!input) return;
						var orig = btn.textContent;
						if (navigator.clipboard && navigator.clipboard.writeText) {
							navigator.clipboard.writeText(input.value).then(function() {
								btn.textContent = '<?php echo $copied; ?>';
								setTimeout(function() { btn.textContent = '<?php echo $copy_label; ?>'; }, 2000);
							});
						} else {
							input.select();
							document.execCommand('copy');
							btn.textContent = '<?php echo $copied; ?>';
							setTimeout(function() { btn.textContent = '<?php echo $copy_label; ?>'; }, 2000);
						}
					});
				});
				document.querySelectorAll('.wpps-create-short-link').forEach(function(btn) {
					btn.addEventListener('click', function() {
						var url = btn.getAttribute('data-url');
						if (!url) return;
						var fd = new FormData();
						fd.append('action', 'wpps_link_shortener_save');
						fd.append('nonce', '<?php echo $nonce; ?>');
						fd.append('destination_url', url);
						fd.append('short_code', '');
						fd.append('active', '1');
						fetch('<?php echo $ajax_url; ?>', { method: 'POST', body: fd })
							.then(function(r) { return r.json(); })
							.then(function(data) {
								if (data.success) location.reload();
								else alert(data.data && data.data.message ? data.data.message : '<?php echo $failed; ?>');
							});
					});
				});
			});
		})();
		</script>
		<?php
	}

	/**
	 * {@inheritdoc}
	 */
	public function getDataLocations(): array {
		return [
			'option_store_keys' => [ self::MODULE_ID ],
			'tables'            => [ self::TABLE_NAME, self::ANALYTICS_TABLE ],
		];
	}

}
