<?php

defined( 'ABSPATH' ) || exit;

/**
 * Login Activity Log module.
 *
 * Records every successful and failed login attempt to a dedicated DB table
 * so site owners can spot brute-force activity and confirm legitimate logins.
 *
 * Schema (`{prefix}bw_dev_login_log`):
 *   id          BIGINT UNSIGNED PK AUTO_INCREMENT
 *   ts          DATETIME (UTC)             — INDEXED for fast prune
 *   user_login  VARCHAR(60)                — attempted username
 *   user_id     BIGINT UNSIGNED NULLABLE   — set on success when known
 *   ip          VARCHAR(45)                — IPv6 max length
 *   user_agent  VARCHAR(255)               — truncated
 *   result      VARCHAR(20)                — 'success' | 'failure'
 *
 * Storage discipline (the "won't bloat your DB" plan):
 *   - Daily wp-cron event prunes entries older than `retention_days` (default 30).
 *   - Hard ceiling of `max_rows` (default 10,000) regardless of date — extra
 *     rows past that are deleted oldest-first.
 *   - Manual "Purge all" button on the settings tab for one-click cleanup.
 *
 * Worst case at defaults: ~10k rows × ~150 bytes ≈ 1.5 MB. Realistic case for
 * a small site: a few hundred KB. Indexed `ts` keeps the prune cheap.
 *
 * Privacy note: this table stores IPs and user agents. On client sites, the
 * privacy policy should mention that login attempts are logged for security.
 *
 * Cloudflare-aware IP detection: prefers `HTTP_CF_CONNECTING_IP` (set by CF
 * for proxied sites) over `REMOTE_ADDR`. Spoof-proof in our deployment because
 * Cloudflare strips client-supplied CF-* headers at the edge.
 *
 * @package BW_Dev
 */

class BW_Dev_Module_Login_Log implements BW_Dev_Module_Interface {

	const CRON_HOOK     = 'bw_dev_login_log_prune';
	const PURGE_ACTION  = 'bw_dev_login_log_purge';
	const PURGE_NONCE   = '_bw_dev_login_log_purge_nonce';
	const TABLE_VERSION = 1;
	const TABLE_VERSION_OPTION = 'bw_dev_login_log_db_version';

	/** Default retention + cap settings. */
	private const DEFAULTS = array(
		'retention_days' => 30,
		'max_rows'       => 10000,
	);

	public function slug(): string {
		return 'login_log';
	}

	public function label(): string {
		return __( 'Login Activity Log', 'bw-dev' );
	}

	public function group(): string {
		return 'security';
	}

	public function default_settings(): array {
		return self::DEFAULTS;
	}

	public function sanitize( array $data ): array {
		$retention = isset( $data['retention_days'] ) ? (int) $data['retention_days'] : self::DEFAULTS['retention_days'];
		$max_rows  = isset( $data['max_rows'] )       ? (int) $data['max_rows']       : self::DEFAULTS['max_rows'];

		$retention = max( 1, min( 365, $retention ) );
		$max_rows  = max( 100, min( 1000000, $max_rows ) );

		return array(
			'retention_days' => $retention,
			'max_rows'       => $max_rows,
		);
	}

	public function register(): void {
		add_action( 'wp_login',        array( $this, 'log_success' ), 10, 2 );
		add_action( 'wp_login_failed', array( $this, 'log_failure' ), 10, 1 );
		add_action( self::CRON_HOOK,   array( $this, 'prune' ) );
		// Registered on both authed + nopriv hooks because admin-post.php's
		// `wp_validate_auth_cookie()` can route real admin requests through the
		// nopriv branch on HTTPS sites missing the `wordpress_sec_*` cookie.
		// Handler does its own caps + nonce check so anon requests safely fail.
		add_action( 'admin_post_' . self::PURGE_ACTION,        array( $this, 'handle_purge' ) );
		add_action( 'admin_post_nopriv_' . self::PURGE_ACTION, array( $this, 'handle_purge' ) );
	}

	/* ---------------------------------------------------------------------
	 * Table management (called from BW_Dev_Migration::on_activation)
	 * ------------------------------------------------------------------- */

	public static function table_name(): string {
		global $wpdb;
		return $wpdb->prefix . 'bw_dev_login_log';
	}

	public static function install_table(): void {
		global $wpdb;
		$installed = (int) get_option( self::TABLE_VERSION_OPTION, 0 );
		if ( $installed >= self::TABLE_VERSION ) {
			return;
		}

		$table           = self::table_name();
		$charset_collate = $wpdb->get_charset_collate();

		// dbDelta is whitespace-sensitive — keep two spaces between key types
		// and column names, no inline comments inside the SQL string.
		$sql = "CREATE TABLE $table (
			id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
			ts DATETIME NOT NULL,
			user_login VARCHAR(60) NOT NULL DEFAULT '',
			user_id BIGINT UNSIGNED DEFAULT NULL,
			ip VARCHAR(45) NOT NULL DEFAULT '',
			user_agent VARCHAR(255) NOT NULL DEFAULT '',
			result VARCHAR(20) NOT NULL DEFAULT '',
			PRIMARY KEY  (id),
			KEY ts_idx (ts),
			KEY user_login_idx (user_login(20))
		) $charset_collate;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );

		update_option( self::TABLE_VERSION_OPTION, self::TABLE_VERSION );
	}

	public static function schedule_cron(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
		}
	}

	public static function unschedule_cron(): void {
		$next = wp_next_scheduled( self::CRON_HOOK );
		if ( $next ) {
			wp_unschedule_event( $next, self::CRON_HOOK );
		}
		wp_clear_scheduled_hook( self::CRON_HOOK );
	}

	/* ---------------------------------------------------------------------
	 * Logging hooks
	 * ------------------------------------------------------------------- */

	/**
	 * @param string  $user_login Username actually used to log in.
	 * @param WP_User $user       Authenticated user object.
	 */
	public function log_success( $user_login, $user ): void {
		$this->insert(
			(string) $user_login,
			isset( $user->ID ) ? (int) $user->ID : null,
			'success'
		);
	}

	/**
	 * @param string $username Attempted username (may not be a real user).
	 */
	public function log_failure( $username ): void {
		$this->insert( (string) $username, null, 'failure' );
	}

	private function insert( string $user_login, ?int $user_id, string $result ): void {
		global $wpdb;

		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			self::table_name(),
			array(
				'ts'         => current_time( 'mysql', true ),
				'user_login' => substr( $user_login, 0, 60 ),
				'user_id'    => $user_id,
				'ip'         => substr( $this->client_ip(), 0, 45 ),
				'user_agent' => substr( (string) ( isset( $_SERVER['HTTP_USER_AGENT'] ) ? wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : '' ), 0, 255 ),
				'result'     => substr( $result, 0, 20 ),
			),
			array( '%s', '%s', null === $user_id ? null : '%d', '%s', '%s', '%s' )
		);
	}

	/**
	 * Cloudflare-aware client IP. Prefer CF-Connecting-IP when present (set by
	 * CF on proxied requests; CF strips any client-supplied CF-* header at the
	 * edge so it is trustworthy in our deployment). Otherwise REMOTE_ADDR.
	 * Returns '' for anything that doesn't validate as an IP.
	 */
	private function client_ip(): string {
		$candidates = array();
		if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
			$candidates[] = wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] );
		}
		if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
			$candidates[] = wp_unslash( $_SERVER['REMOTE_ADDR'] );
		}
		foreach ( $candidates as $ip ) {
			$ip = trim( (string) $ip );
			if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				return $ip;
			}
		}
		return '';
	}

	/* ---------------------------------------------------------------------
	 * Pruning
	 * ------------------------------------------------------------------- */

	public function prune(): void {
		global $wpdb;
		$table     = self::table_name();
		$retention = (int) bw_dev()->settings()->get( $this->slug(), 'retention_days', self::DEFAULTS['retention_days'] );
		$max_rows  = (int) bw_dev()->settings()->get( $this->slug(), 'max_rows', self::DEFAULTS['max_rows'] );
		$retention = max( 1, min( 365, $retention ) );
		$max_rows  = max( 100, min( 1000000, $max_rows ) );

		// Date-based prune.
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $retention * DAY_IN_SECONDS ) );
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE ts < %s", $cutoff ) ); // phpcs:ignore WordPress.DB

		// Hard row-count ceiling.
		$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB
		if ( $count > $max_rows ) {
			$excess = $count - $max_rows;
			$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", $excess ) ); // phpcs:ignore WordPress.DB
		}
	}

	/* ---------------------------------------------------------------------
	 * Purge-all action
	 * ------------------------------------------------------------------- */

	public function handle_purge(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to do this.', 'bw-dev' ), '', array( 'response' => 403 ) );
		}
		// Custom nonce name so it doesn't collide with the outer settings-form's _wpnonce
		// (the module's settings tab is rendered inside <form action="options.php">).
		check_admin_referer( self::PURGE_ACTION, self::PURGE_NONCE );

		global $wpdb;
		$table = self::table_name();
		$wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore WordPress.DB

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'   => 'bw-dev',
					'tab'    => $this->slug(),
					'purged' => '1',
				),
				admin_url( 'options-general.php' )
			)
		);
		exit;
	}

	/* ---------------------------------------------------------------------
	 * Settings tab
	 * ------------------------------------------------------------------- */

	public function render_tab(): void {
		global $wpdb;
		$table     = self::table_name();
		$retention = (int) bw_dev()->settings()->get( $this->slug(), 'retention_days', self::DEFAULTS['retention_days'] );
		$max_rows  = (int) bw_dev()->settings()->get( $this->slug(), 'max_rows', self::DEFAULTS['max_rows'] );
		$prefix    = BW_Dev_Settings::OPTION . '[' . $this->slug() . ']';

		$total   = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB
		$rows    = $wpdb->get_results( "SELECT id, ts, user_login, user_id, ip, user_agent, result FROM {$table} ORDER BY id DESC LIMIT 50" ); // phpcs:ignore WordPress.DB
		$next    = wp_next_scheduled( self::CRON_HOOK );

		$query  = wp_unslash( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$purged = ! empty( $query['purged'] );
		?>
		<p class="description">
			<?php esc_html_e( 'Records every login attempt — successful and failed — to a dedicated table. The most recent 50 entries are shown below; older entries are pruned automatically.', 'bw-dev' ); ?>
		</p>

		<?php if ( $purged ) : ?>
			<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Login activity log purged.', 'bw-dev' ); ?></p></div>
		<?php endif; ?>

		<div style="background:#fff;border-left:4px solid #2271b1;padding:12px 16px;margin:14px 0;max-width:720px;">
			<strong><?php esc_html_e( 'Privacy note', 'bw-dev' ); ?></strong>
			<p style="margin:6px 0 0;">
				<?php esc_html_e( 'This table stores IP addresses and user agents of login attempts. If your site has a privacy policy, mention that login attempts are logged for security.', 'bw-dev' ); ?>
			</p>
		</div>

		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row">
						<label for="bw-dev-loglog-retention"><?php esc_html_e( 'Retention (days)', 'bw-dev' ); ?></label>
					</th>
					<td>
						<input type="number" min="1" max="365" id="bw-dev-loglog-retention" name="<?php echo esc_attr( $prefix . '[retention_days]' ); ?>" value="<?php echo esc_attr( (string) $retention ); ?>" class="small-text" />
						<p class="description"><?php esc_html_e( 'Entries older than this are dropped by the daily prune cron. 1–365.', 'bw-dev' ); ?></p>
					</td>
				</tr>
				<tr>
					<th scope="row">
						<label for="bw-dev-loglog-maxrows"><?php esc_html_e( 'Hard row cap', 'bw-dev' ); ?></label>
					</th>
					<td>
						<input type="number" min="100" max="1000000" id="bw-dev-loglog-maxrows" name="<?php echo esc_attr( $prefix . '[max_rows]' ); ?>" value="<?php echo esc_attr( (string) $max_rows ); ?>" class="small-text" />
						<p class="description"><?php esc_html_e( 'Belt-and-suspenders ceiling. If the table ever exceeds this, the oldest entries are deleted to bring it back under the cap regardless of date. 100–1,000,000.', 'bw-dev' ); ?></p>
					</td>
				</tr>
			</tbody>
		</table>

		<p class="description">
			<?php
			printf(
				/* translators: 1: total row count, 2: ISO timestamp of next scheduled prune */
				esc_html__( 'Currently storing %1$s entries. Next prune: %2$s (UTC).', 'bw-dev' ),
				esc_html( number_format_i18n( $total ) ),
				esc_html( $next ? gmdate( 'Y-m-d H:i:s', $next ) : __( 'not scheduled', 'bw-dev' ) )
			);
			?>
		</p>

		<?php
		// IMPORTANT: this section runs inside the outer Settings API form
		// (<form action="options.php">). HTML doesn't allow nested forms, so
		// the button uses HTML5 formaction + formmethod to override the parent
		// form's submit target. The custom nonce field below uses PURGE_NONCE
		// (not the default _wpnonce) so it doesn't overwrite the outer settings
		// form's nonce. handle_purge() reads it via check_admin_referer( ..., PURGE_NONCE ).
		$purge_url = admin_url( 'admin-post.php?action=' . self::PURGE_ACTION );
		?>
		<p style="margin-top:20px;">
			<input type="hidden" name="<?php echo esc_attr( self::PURGE_NONCE ); ?>" value="<?php echo esc_attr( wp_create_nonce( self::PURGE_ACTION ) ); ?>" />
			<button type="submit"
			        formaction="<?php echo esc_url( $purge_url ); ?>"
			        formmethod="post"
			        formnovalidate
			        class="button button-secondary"
			        onclick="return confirm('<?php echo esc_js( __( 'Delete every entry in the login activity log? This cannot be undone.', 'bw-dev' ) ); ?>');">
				<?php esc_html_e( 'Purge all entries', 'bw-dev' ); ?>
			</button>
		</p>

		<h3 style="margin-top:30px;"><?php esc_html_e( 'Recent activity (most recent 50)', 'bw-dev' ); ?></h3>

		<?php if ( empty( $rows ) ) : ?>
			<p><?php esc_html_e( 'No login attempts recorded yet.', 'bw-dev' ); ?></p>
		<?php else : ?>
			<table class="widefat striped">
				<thead>
					<tr>
						<th><?php esc_html_e( 'When (UTC)', 'bw-dev' ); ?></th>
						<th><?php esc_html_e( 'Result', 'bw-dev' ); ?></th>
						<th><?php esc_html_e( 'Username tried', 'bw-dev' ); ?></th>
						<th><?php esc_html_e( 'IP', 'bw-dev' ); ?></th>
						<th><?php esc_html_e( 'User agent', 'bw-dev' ); ?></th>
					</tr>
				</thead>
				<tbody>
					<?php foreach ( $rows as $row ) : ?>
						<tr>
							<td><?php echo esc_html( $row->ts ); ?></td>
							<td>
								<?php if ( 'success' === $row->result ) : ?>
									<span style="color:#00a32a;font-weight:600;"><?php esc_html_e( 'Success', 'bw-dev' ); ?></span>
								<?php else : ?>
									<span style="color:#d63638;font-weight:600;"><?php esc_html_e( 'Failed', 'bw-dev' ); ?></span>
								<?php endif; ?>
							</td>
							<td><code><?php echo esc_html( $row->user_login ); ?></code></td>
							<td><code><?php echo esc_html( $row->ip ); ?></code></td>
							<td style="font-size:11px;color:#646970;max-width:300px;word-break:break-all;"><?php echo esc_html( $row->user_agent ); ?></td>
						</tr>
					<?php endforeach; ?>
				</tbody>
			</table>
		<?php endif; ?>
		<?php
	}

	public function uninstall(): void {
		global $wpdb;
		$table = self::table_name();
		$wpdb->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB
		delete_option( self::TABLE_VERSION_OPTION );
		self::unschedule_cron();
	}
}
