<?php
defined( 'ABSPATH' ) || exit;

/**
 * Storage for cross-domain handoff records.
 *
 * A record holds a snapshot of a visitor's attribution datapoints, addressed by a
 * bearer token that travels on an outbound link to a configured third-party domain.
 *
 * Lifecycle:
 *   mint (pending, short TTL)
 *     ├─ claim   -> serve the payload ONCE, then cross-origin reads 404
 *     ├─ confirm -> promote to `confirmed`, extend expiry to the retention setting
 *     └─ expiry  -> pruned
 *
 * Two clocks, deliberately different. The pending TTL is an abuse control and stays
 * short — it is what stops the create endpoint being free permanent storage for
 * anyone who finds it. The confirmed retention is a business setting and may be
 * years, or unlimited: a confirmed record is one a real person produced by
 * submitting a form.
 *
 * This is the plugin's only custom table, and it is created lazily on first enable
 * rather than on activation, so sites that never use handoff never get it.
 */
class BW_Lead_AI_Handoff_Store {

	/** Bumped when the schema changes; drives dbDelta on upgrade. */
	const SCHEMA_VERSION = 1;

	const SCHEMA_OPTION = 'bw_lead_ai_handoff_schema';

	/** Expiry stand-in for "unlimited retention". Comparisons stay simple. */
	const NEVER = '2099-12-31 23:59:59';

	const STATUS_PENDING   = 'pending';
	const STATUS_CONFIRMED = 'confirmed';

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

	/**
	 * Create or upgrade the table. Called on first enable of the feature and on
	 * schema version bumps — never on plugin activation.
	 *
	 * @return bool Whether the table exists afterwards.
	 */
	/**
	 * Lifecycle wiring: create the table when the feature is switched on, and keep
	 * the prune cron in step with whether it is enabled.
	 */
	public static function register() {
		add_action( 'bw_lead_ai_handoff_prune', array( __CLASS__, 'prune' ) );
		add_action( 'update_option_' . BW_LEAD_AI_OPTION, array( __CLASS__, 'sync_lifecycle' ), 10, 0 );
		add_action( 'admin_init', array( __CLASS__, 'sync_lifecycle' ) );
	}

	public static function sync_lifecycle() {
		$scheduled = wp_next_scheduled( 'bw_lead_ai_handoff_prune' );

		if ( ! BW_Lead_AI_Settings::handoff_enabled() ) {
			if ( $scheduled ) {
				wp_unschedule_event( $scheduled, 'bw_lead_ai_handoff_prune' );
			}
			return;
		}

		self::maybe_create_table();
		if ( ! $scheduled ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'bw_lead_ai_handoff_prune' );
		}
	}

	public static function maybe_create_table() {
		// Fast path trusts the schema option: verifying with SHOW TABLES on every
		// request would put a query on every page load for no benefit.
		if ( (int) get_option( self::SCHEMA_OPTION ) === self::SCHEMA_VERSION ) {
			return true;
		}

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

		// Note: dbDelta is whitespace- and format-sensitive. Two spaces after PRIMARY
		// KEY, lowercase `key`, one field per line.
		$sql = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			token_hash char(64) NOT NULL,
			payload longtext NOT NULL,
			status varchar(12) NOT NULL DEFAULT 'pending',
			data_claimed_at datetime DEFAULT NULL,
			confirmed_at datetime DEFAULT NULL,
			context varchar(200) DEFAULT NULL,
			created_at datetime NOT NULL,
			expires_at datetime NOT NULL,
			PRIMARY KEY  (id),
			UNIQUE KEY token_hash (token_hash),
			KEY expires_at (expires_at)
		) {$collate};";

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

		if ( ! self::table_exists() ) {
			return false;
		}
		update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION );
		return true;
	}

	public static function table_exists() {
		global $wpdb;
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is built from $wpdb->prefix.
		return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
	}

	public static function drop_table() {
		global $wpdb;
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is built from $wpdb->prefix.
		$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
		delete_option( self::SCHEMA_OPTION );
	}

	/**
	 * A fresh 128-bit bearer token, hex encoded.
	 *
	 * This addresses someone's browsing history, so it comes from a CSPRNG and
	 * nothing else. Stored only as a sha256 hash, so a database dump or backup leak
	 * contains no usable tokens.
	 */
	public static function generate_token() {
		return bin2hex( random_bytes( 16 ) );
	}

	public static function hash_token( $token ) {
		return hash( 'sha256', (string) $token );
	}

	/**
	 * Whether a string is shaped like one of our tokens. Cheap rejection before
	 * touching the database.
	 */
	public static function is_token_shaped( $token ) {
		return is_string( $token ) && 1 === preg_match( '/^[a-f0-9]{32}$/', $token );
	}

	/**
	 * Create a record.
	 *
	 * @param array $payload    Already filtered to the enabled datapoints.
	 * @param int   $ttl_minutes Pending lifetime.
	 * @return string|WP_Error The plain token.
	 */
	public static function mint( $payload, $ttl_minutes ) {
		if ( ! self::table_exists() ) {
			return new WP_Error( 'bw_lead_ai_no_table', __( 'Handoff storage is unavailable.', 'bw-lead-ai' ) );
		}

		global $wpdb;
		$token = self::generate_token();
		$now   = current_time( 'mysql', true );

		$inserted = $wpdb->insert(
			self::table_name(),
			array(
				'token_hash' => self::hash_token( $token ),
				'payload'    => wp_json_encode( $payload ),
				'status'     => self::STATUS_PENDING,
				'created_at' => $now,
				'expires_at' => gmdate( 'Y-m-d H:i:s', time() + ( absint( $ttl_minutes ) * MINUTE_IN_SECONDS ) ),
			),
			array( '%s', '%s', '%s', '%s', '%s' )
		);

		if ( ! $inserted ) {
			return new WP_Error( 'bw_lead_ai_insert_failed', __( 'Could not store the handoff record.', 'bw-lead-ai' ) );
		}
		return $token;
	}

	/**
	 * Replace the payload on a live pending record.
	 *
	 * Used when the visitor actually clicks the outbound link, so the destination
	 * gets the journey as of the click rather than as of page load. Deliberately
	 * does NOT extend expiry — a refresh loop must not keep a record alive forever.
	 */
	public static function refresh( $token, $payload ) {
		if ( ! self::is_token_shaped( $token ) || ! self::table_exists() ) {
			return false;
		}
		global $wpdb;
		$table = self::table_name();
		$hash  = self::hash_token( $token );
		$now   = current_time( 'mysql', true );

		// Existence is checked separately rather than inferred from affected rows.
		// MySQL reports 0 changed rows when an UPDATE writes values identical to
		// the ones already there, so affected-rows cannot tell "no such record"
		// apart from "same payload as last time" — and a false negative here makes
		// the caller mint a second token while the page's links still carry the
		// first. See claim() for the one place affected-rows IS the right test.
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				"SELECT id FROM {$table} WHERE token_hash = %s AND status = %s AND expires_at > %s",
				$hash,
				self::STATUS_PENDING,
				$now
			)
		);
		if ( ! $exists ) {
			return false;
		}

		$wpdb->update(
			$table,
			array( 'payload' => wp_json_encode( $payload ) ),
			array( 'id' => (int) $exists ),
			array( '%s' ),
			array( '%d' )
		);
		return true;
	}

	/**
	 * Claim a record's payload — once.
	 *
	 * Burn-on-read: the token stops working cross-origin after the first successful
	 * claim. It will have been written into the destination's URL bar, browser
	 * history, access logs and any third-party script on their page, so shrinking
	 * the exposure window from the full TTL to a single round trip is worth more
	 * than the convenience of a replayable read. Retry-on-404 on the destination
	 * side still works, because retries only matter when the first read failed.
	 *
	 * The claim is an atomic conditional UPDATE, so two parallel requests cannot
	 * both be served.
	 *
	 * @return array|null Payload, or null when missing/expired/already claimed.
	 */
	public static function claim( $token ) {
		if ( ! self::is_token_shaped( $token ) || ! self::table_exists() ) {
			return null;
		}
		global $wpdb;
		$table = self::table_name();
		$hash  = self::hash_token( $token );
		$now   = current_time( 'mysql', true );

		$claimed = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				"UPDATE {$table} SET data_claimed_at = %s WHERE token_hash = %s AND data_claimed_at IS NULL AND expires_at > %s",
				$now,
				$hash,
				$now
			)
		);
		if ( ! $claimed ) {
			return null;
		}

		$payload = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				"SELECT payload FROM {$table} WHERE token_hash = %s",
				$hash
			)
		);
		$decoded = json_decode( (string) $payload, true );
		return is_array( $decoded ) ? $decoded : null;
	}

	/**
	 * Promote a record to confirmed and extend its expiry to the retention setting.
	 *
	 * This is what the destination fires on its form-submit trigger. Idempotent:
	 * re-confirming an already-confirmed record succeeds without changing anything
	 * except an updated context, because networks retry.
	 *
	 * @param string   $token
	 * @param string   $context   Optional short label, e.g. "Enrolment form".
	 * @param int|null $retention Days to keep, or null for unlimited.
	 */
	public static function confirm( $token, $context, $retention_days ) {
		if ( ! self::is_token_shaped( $token ) || ! self::table_exists() ) {
			return false;
		}
		global $wpdb;
		$table = self::table_name();
		$hash  = self::hash_token( $token );
		$now   = current_time( 'mysql', true );

		// Same reasoning as refresh(): a repeat confirm writes identical values, so
		// affected-rows would be 0 and the caller would see a spurious 404. Confirm
		// has to be idempotent — the destination's tag manager will retry.
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				"SELECT id FROM {$table} WHERE token_hash = %s AND expires_at > %s",
				$hash,
				$now
			)
		);
		if ( ! $exists ) {
			return false;
		}

		$expires = ( null === $retention_days )
			? self::NEVER
			: gmdate( 'Y-m-d H:i:s', time() + ( absint( $retention_days ) * DAY_IN_SECONDS ) );

		$wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				"UPDATE {$table} SET status = %s, confirmed_at = COALESCE(confirmed_at, %s), context = %s, expires_at = %s WHERE id = %d",
				self::STATUS_CONFIRMED,
				$now,
				( '' === $context ) ? null : $context,
				$expires,
				(int) $exists
			)
		);
		return true;
	}

	/**
	 * Full record for the logged-in viewer. Unlike claim(), this does not burn the
	 * token — a marketer may open the same link repeatedly, and the data never
	 * leaves the site here.
	 */
	public static function get_record( $token ) {
		if ( ! self::is_token_shaped( $token ) || ! self::table_exists() ) {
			return null;
		}
		global $wpdb;
		$row = $wpdb->get_row(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				'SELECT * FROM ' . self::table_name() . ' WHERE token_hash = %s AND expires_at > %s',
				self::hash_token( $token ),
				current_time( 'mysql', true )
			),
			ARRAY_A
		);
		if ( ! $row ) {
			return null;
		}
		$row['payload'] = json_decode( (string) $row['payload'], true );
		if ( ! is_array( $row['payload'] ) ) {
			$row['payload'] = array();
		}
		return $row;
	}

	public static function delete( $token ) {
		if ( ! self::is_token_shaped( $token ) || ! self::table_exists() ) {
			return false;
		}
		global $wpdb;
		return (bool) $wpdb->delete( self::table_name(), array( 'token_hash' => self::hash_token( $token ) ), array( '%s' ) );
	}

	/**
	 * How many pending records exist. Used as a global abuse ceiling so a flood
	 * cannot fill the table even if per-IP limits are evaded across many addresses.
	 */
	public static function pending_count() {
		if ( ! self::table_exists() ) {
			return 0;
		}
		global $wpdb;
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				'SELECT COUNT(*) FROM ' . self::table_name() . ' WHERE status = %s AND expires_at > %s',
				self::STATUS_PENDING,
				current_time( 'mysql', true )
			)
		);
	}

	/**
	 * Delete expired records. Expiry is ALSO enforced at read time in every lookup
	 * above, so a late or missing cron run is a housekeeping problem, never a
	 * security one.
	 */
	public static function prune() {
		if ( ! self::table_exists() ) {
			return 0;
		}
		global $wpdb;
		return (int) $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
				'DELETE FROM ' . self::table_name() . ' WHERE expires_at <= %s',
				current_time( 'mysql', true )
			)
		);
	}

	public static function purge_all() {
		if ( ! self::table_exists() ) {
			return 0;
		}
		global $wpdb;
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
		return (int) $wpdb->query( 'DELETE FROM ' . self::table_name() );
	}

	public static function stats() {
		if ( ! self::table_exists() ) {
			return array( 'pending' => 0, 'confirmed' => 0 );
		}
		global $wpdb;
		$table = self::table_name();
		$now   = current_time( 'mysql', true );
		return array(
			'pending'   => self::pending_count(),
			'confirmed' => (int) $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
					"SELECT COUNT(*) FROM {$table} WHERE status = %s AND expires_at > %s",
					self::STATUS_CONFIRMED,
					$now
				)
			),
		);
	}
}
