<?php
/**
 * Data-access layer for customer portal sessions and magic-link tokens.
 *
 * A single wrenchpress_customer_sessions table holds both the short-lived
 * pending magic-link tokens and the longer-lived active portal sessions,
 * distinguished by a status column ('pending' then 'active'). Only the SHA-256
 * hash of a token is ever stored; the plaintext token never reaches this layer.
 *
 * All reads and writes go through $wpdb with prepared statements and explicit
 * format arrays. The table identifier is bound with %i and every value with
 * %d/%s; no caller interpolates input into SQL.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Customer_Sessions_Repository
 */
class WrenchPress_Customer_Sessions_Repository {

	/**
	 * The zero datetime used for an as-yet-unused pending token.
	 */
	const ZERO_DATETIME = '0000-00-00 00:00:00';

	/**
	 * Fully qualified customer-sessions table name.
	 *
	 * @return string
	 */
	protected static function table() {
		global $wpdb;

		return $wpdb->prefix . 'wrenchpress_customer_sessions';
	}

	/**
	 * Insert a pending magic-link token row.
	 *
	 * Stores only the supplied token hash (never a plaintext token), stamps
	 * created_at, and leaves used_at at the zero datetime until the token is
	 * activated.
	 *
	 * @param int    $customer_id Customer the token belongs to.
	 * @param string $token_hash  SHA-256 hash of the single-use token.
	 * @param string $ip          Request IP fingerprint.
	 * @param string $ua          Request user-agent fingerprint.
	 * @param string $expires_at  MySQL datetime the pending token expires at.
	 * @return int|false New row id, or false on insert failure.
	 */
	public static function insert_pending_token( $customer_id, $token_hash, $ip, $ua, $expires_at ) {
		global $wpdb;

		$row = array(
			'customer_id' => absint( $customer_id ),
			'token_hash'  => (string) $token_hash,
			'status'      => 'pending',
			'ip'          => (string) $ip,
			'ua'          => (string) $ua,
			'expires_at'  => (string) $expires_at,
			'used_at'     => self::ZERO_DATETIME,
			'created_at'  => current_time( 'mysql' ),
		);

		$formats = array(
			'%d', // customer_id.
			'%s', // token_hash.
			'%s', // status.
			'%s', // ip.
			'%s', // ua.
			'%s', // expires_at.
			'%s', // used_at.
			'%s', // created_at.
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$inserted = $wpdb->insert( self::table(), $row, $formats );

		if ( false === $inserted ) {
			return false;
		}

		return (int) $wpdb->insert_id;
	}

	/**
	 * Find a live pending token by its hash.
	 *
	 * Returns the row only when it is still pending, has never been used
	 * (used_at is the zero datetime), and has not expired. This is the guarded
	 * lookup a magic-link click verifies against before activating a session.
	 *
	 * @param string $token_hash SHA-256 hash of the presented token.
	 * @return object|null Row object, or null when no live pending token matches.
	 */
	public static function find_pending_by_hash( $token_hash ) {
		global $wpdb;

		$token_hash = (string) $token_hash;

		if ( '' === $token_hash ) {
			return null;
		}

		$table = self::table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE token_hash = %s AND status = %s AND used_at = %s AND expires_at > %s',
				$table,
				$token_hash,
				'pending',
				self::ZERO_DATETIME,
				current_time( 'mysql' )
			)
		);

		return $row ? $row : null;
	}

	/**
	 * Activate a pending token into an active session (single-use).
	 *
	 * Flips status to 'active', stamps used_at, and sets the new (30-day)
	 * expiry, but only while the row is still pending and unused. The WHERE
	 * guard on status/used_at makes this single-use: a second activation on an
	 * already-consumed row updates zero rows and returns false.
	 *
	 * @param int    $id             Session row id.
	 * @param string $new_expires_at MySQL datetime for the active session expiry.
	 * @return bool True when a pending row was activated, false otherwise.
	 */
	public static function activate( $id, $new_expires_at ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write, intentionally not object-cached; guarded update for single-use activation.
		$updated = $wpdb->query(
			$wpdb->prepare(
				'UPDATE %i SET status = %s, used_at = %s, expires_at = %s WHERE id = %d AND status = %s AND used_at = %s',
				self::table(),
				'active',
				current_time( 'mysql' ),
				(string) $new_expires_at,
				$id,
				'pending',
				self::ZERO_DATETIME
			)
		);

		return is_int( $updated ) && $updated > 0;
	}

	/**
	 * Load a live active session by id.
	 *
	 * Returns the row only when it is active and unexpired; the caller derives
	 * the authenticated customer_id from it and never from request input.
	 *
	 * @param int $id Session row id.
	 * @return object|null Row object, or null when no live active session matches.
	 */
	public static function get_active( $id ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return null;
		}

		$table = self::table();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table, intentionally not object-cached.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE id = %d AND status = %s AND expires_at > %s',
				$table,
				$id,
				'active',
				current_time( 'mysql' )
			)
		);

		return $row ? $row : null;
	}

	/**
	 * Destroy a session row (portal sign-out).
	 *
	 * @param int $id Session row id.
	 * @return bool True on success, false on invalid id or DB failure.
	 */
	public static function destroy( $id ) {
		global $wpdb;

		$id = absint( $id );

		if ( 0 === $id ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$deleted = $wpdb->delete( self::table(), array( 'id' => $id ), array( '%d' ) );

		return false !== $deleted;
	}

	/**
	 * Delete every session and pending-token row belonging to a customer.
	 *
	 * Called by the GDPR eraser (08-06) so that erasing a customer removes the
	 * sign-in IP and user-agent fingerprint carried on their portal-session rows.
	 * A person's own session rows are not a financial record, so this runs in both
	 * the anonymize-in-place branch and the hard-delete branch of the eraser.
	 *
	 * @param int $customer_id Customer whose session/token rows to remove.
	 * @return int Rows deleted (0 on an invalid id or when the customer has none).
	 */
	public static function delete_for_customer( $customer_id ) {
		global $wpdb;

		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return 0;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table write via the $wpdb array API, intentionally not object-cached.
		$deleted = $wpdb->delete( self::table(), array( 'customer_id' => $customer_id ), array( '%d' ) );

		return is_int( $deleted ) ? $deleted : 0;
	}

	/**
	 * Delete every expired row regardless of status (retention cleanup).
	 *
	 * Idempotent and safe to re-run; the scheduled invocation is wired into the
	 * retention runner (08-07) so no expired token, session, or ip/ua
	 * fingerprint lingers past its expiry window.
	 *
	 * @return int|false Rows deleted, or false on DB failure.
	 */
	public static function purge_expired() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table maintenance delete, intentionally not object-cached.
		return $wpdb->query(
			$wpdb->prepare(
				'DELETE FROM %i WHERE expires_at < %s',
				self::table(),
				current_time( 'mysql' )
			)
		);
	}
}
