<?php
/**
 * Admin user-journey capture for product analytics (tabs, connection outcomes, configuration flags).
 *
 * Timestamps are stored as ISO 8601 UTC strings (e.g. 2026-05-04T14:30:00Z). Deactivation / email handoff is separate.
 *
 * @package object-data-sync-for-salesforce\Services
 */

namespace MoSfSyncSalesforce\Services;

use MoSfSyncSalesforce\Helper\Instance;
use MoSfSyncSalesforce\Helper\Plugin_Constants;
use MoSfSyncSalesforce\Services\DB_Utils;
use MoSfSyncSalesforce\Services\Utils;

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

/**
 * Captures and persists non-PII admin journey data.
 */
class User_Journey_Service {
	use Instance;

	/**
	 * Maximum recent tab visits kept (most recent only).
	 *
	 * @var int
	 */
	private const MO_SF_JOURNEY_RECENT_MAX = 5;

	/**
	 * Maximum length for stored error text.
	 *
	 * @var int
	 */
	private const MO_SF_JOURNEY_ERR_MAX_LEN = 500;

	/**
	 * Schema version for stored option structure.
	 *
	 * @var int
	 */
	private const MO_SF_JOURNEY_SCHEMA = 1;

	/**
	 * Instance of DB_Utils.
	 *
	 * @var object
	 */
	private $db_utils;

	/**
	 * Creates instance of the class.
	 *
	 * @return User_Journey_Service
	 */
	public static function instance() {
		if ( is_null( self::$instance ) ) {
			self::$instance           = new self();
			self::$instance->db_utils = DB_Utils::instance();
		}
		return self::$instance;
	}

	/**
	 * Current UTC time as ISO 8601 with Z suffix.
	 *
	 * @return string
	 */
	private function mo_sf_sync_journey_utc_now() {
		return gmdate( 'Y-m-d\TH:i:s\Z' );
	}

	/**
	 * Optional mapping label from tab-visit context (field-mapping deep link). Not PII.
	 *
	 * @param array $context May contain key mapping_label from admin URL.
	 * @return string Sanitized label or empty string.
	 */
	private function mo_sf_sync_journey_get_mapping_label_from_context( $context ) {
		if ( empty( $context ) || ! is_array( $context ) || ! isset( $context['mapping_label'] ) || ! is_string( $context['mapping_label'] ) ) {
			return '';
		}
		$label = sanitize_text_field( $context['mapping_label'] );
		if ( strlen( $label ) > 128 ) {
			return substr( $label, 0, 128 );
		}
		return $label;
	}

	/**
	 * Normalizes a stored timestamp to ISO 8601 UTC for snapshots (supports legacy Unix integers).
	 *
	 * @param mixed $value Stored value.
	 * @return string ISO 8601 UTC or empty string.
	 */
	private function mo_sf_sync_journey_normalize_ts_for_snapshot( $value ) {
		if ( null === $value || '' === $value ) {
			return '';
		}
		if ( is_numeric( $value ) ) {
			return gmdate( 'Y-m-d\TH:i:s\Z', (int) $value );
		}
		if ( is_string( $value ) ) {
			return sanitize_text_field( $value );
		}
		return '';
	}

	/**
	 * Normalizes first-connection timestamp for snapshot (null when never connected).
	 *
	 * @param mixed $value Stored value.
	 * @return string|null
	 */
	private function mo_sf_sync_journey_normalize_first_success_snapshot( $value ) {
		if ( null === $value || '' === $value ) {
			return null;
		}
		if ( is_numeric( $value ) ) {
			return gmdate( 'Y-m-d\TH:i:s\Z', (int) $value );
		}
		if ( is_string( $value ) ) {
			return sanitize_text_field( $value );
		}
		return null;
	}

	/**
	 * Builds a snapshot row for one tab visit (handles legacy visited/context keys).
	 *
	 * @param array $entry Raw recent_visits entry.
	 * @return array
	 */
	private function mo_sf_sync_journey_normalize_recent_visit_for_snapshot( $entry ) {
		if ( ! is_array( $entry ) ) {
			return array();
		}
		$tab    = isset( $entry['tab'] ) ? sanitize_key( $entry['tab'] ) : '';
		$raw_ts = '';
		if ( isset( $entry['visited_at'] ) ) {
			$raw_ts = $entry['visited_at'];
		} elseif ( isset( $entry['visited'] ) ) {
			$raw_ts = $entry['visited'];
		}
		$out = array(
			'tab'        => $tab,
			'visited_at' => $this->mo_sf_sync_journey_normalize_ts_for_snapshot( $raw_ts ),
		);
		$ml  = '';
		if ( isset( $entry['mapping_label'] ) && is_string( $entry['mapping_label'] ) ) {
			$ml = sanitize_text_field( $entry['mapping_label'] );
		} elseif ( isset( $entry['context']['mapping_label'] ) && is_string( $entry['context']['mapping_label'] ) ) {
			$ml = sanitize_text_field( $entry['context']['mapping_label'] );
		}
		if ( '' !== $ml ) {
			$out['mapping_label'] = strlen( $ml ) > 128 ? substr( $ml, 0, 128 ) : $ml;
		}
		return $out;
	}

	/**
	 * Merges defaults over existing state.
	 *
	 * @param array|false $existing Raw state from the database.
	 * @return array
	 */
	private function mo_sf_sync_journey_merge_defaults( $existing ) {
		$defaults = array(
			'schema_version'                 => self::MO_SF_JOURNEY_SCHEMA,
			'tabs_visited'                   => array(),
			'last_tab'                       => '',
			'last_visit_at'                  => '',
			'recent_visits'                  => array(),
			'connection_status'              => 'never',
			'connection_last_error_code'     => '',
			'connection_last_error_message'  => '',
			'connection_last_attempt_at'     => '',
			'first_successful_connection_at' => null,
			'object_mapping_ever_configured' => false,
			'realtime_wp_to_sf_enabled'      => false,
		);

		if ( ! is_array( $existing ) ) {
			return $defaults;
		}

		return array_merge( $defaults, $existing );
	}

	/**
	 * Loads state from the database.
	 *
	 * @return array
	 */
	private function mo_sf_sync_journey_get_state() {
		$raw = get_option( Plugin_Constants::USER_JOURNEY_OBJECT, array() );
		return $this->mo_sf_sync_journey_merge_defaults( $raw );
	}

	/**
	 * Persists state.
	 *
	 * @param array $state State array.
	 * @return void
	 */
	private function mo_sf_sync_journey_persist( array $state ) {
		$state['schema_version'] = self::MO_SF_JOURNEY_SCHEMA;
		update_option( Plugin_Constants::USER_JOURNEY_OBJECT, $state, false );
	}

	/**
	 * Truncates a string for storage.
	 *
	 * @param string|null $text Raw text.
	 * @return string
	 */
	private function mo_sf_sync_journey_truncate_message( $text ) {
		if ( empty( $text ) || ! is_string( $text ) ) {
			return '';
		}
		$clean = sanitize_text_field( $text );
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' )
			&& mb_strlen( $clean ) > self::MO_SF_JOURNEY_ERR_MAX_LEN ) {
			return mb_substr( $clean, 0, self::MO_SF_JOURNEY_ERR_MAX_LEN );
		}
		if ( strlen( $clean ) > self::MO_SF_JOURNEY_ERR_MAX_LEN ) {
			return substr( $clean, 0, self::MO_SF_JOURNEY_ERR_MAX_LEN );
		}
		return $clean;
	}

	/**
	 * Sanitizes tab slug.
	 *
	 * @param string $tab Tab identifier.
	 * @return string
	 */
	private function mo_sf_sync_journey_sanitize_tab( $tab ) {
		$key = is_string( $tab ) ? sanitize_key( $tab ) : '';
		return $key ? $key : 'unknown';
	}

	/**
	 * Records a plugin settings tab visit (admin).
	 *
	 * @param string $tab     Tab slug (e.g. app_config, manage_users).
	 * @param array  $context Optional; key mapping_label = internal mapping row label (not user email/PII).
	 * @return void
	 */
	public function mo_sf_sync_journey_record_tab_visit( $tab, $context = array() ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$state   = $this->mo_sf_sync_journey_get_state();
		$tab_key = $this->mo_sf_sync_journey_sanitize_tab( $tab );
		$now     = $this->mo_sf_sync_journey_utc_now();

		if ( ! isset( $state['tabs_visited'][ $tab_key ] ) ) {
			$state['tabs_visited'][ $tab_key ] = 0;
		}
		++$state['tabs_visited'][ $tab_key ];

		$state['last_tab']      = $tab_key;
		$state['last_visit_at'] = $now;

		$mapping_label = $this->mo_sf_sync_journey_get_mapping_label_from_context( $context );
		$visit_row     = array(
			'tab'        => $tab_key,
			'visited_at' => $now,
		);
		if ( '' !== $mapping_label ) {
			$visit_row['mapping_label'] = $mapping_label;
		}
		$state['recent_visits'][] = $visit_row;

		$count = count( $state['recent_visits'] );
		if ( $count > self::MO_SF_JOURNEY_RECENT_MAX ) {
			$state['recent_visits'] = array_slice( $state['recent_visits'], $count - self::MO_SF_JOURNEY_RECENT_MAX );
		}

		$this->mo_sf_sync_journey_refresh_derived_state( $state );
		$this->mo_sf_sync_journey_persist( $state );
	}

	/**
	 * Records Salesforce OAuth connection outcome.
	 *
	 * @param bool        $success       Whether authorization succeeded.
	 * @param string|null $error_code    Short code (e.g. MOSFSYNCERR003).
	 * @param string|null $error_message Failure description when unsuccessful.
	 * @return void
	 */
	public function mo_sf_sync_journey_record_connection_attempt( $success, $error_code = null, $error_message = null ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$state                               = $this->mo_sf_sync_journey_get_state();
		$state['connection_last_attempt_at'] = $this->mo_sf_sync_journey_utc_now();

		if ( $success ) {
			$state['connection_status']             = 'success';
			$state['connection_last_error_code']    = '';
			$state['connection_last_error_message'] = '';
			if ( empty( $state['first_successful_connection_at'] ) ) {
				$state['first_successful_connection_at'] = $this->mo_sf_sync_journey_utc_now();
			}
		} else {
			$state['connection_status']             = 'failed';
			$state['connection_last_error_code']    = is_string( $error_code ) ? sanitize_text_field( $error_code ) : '';
			$state['connection_last_error_message'] = $this->mo_sf_sync_journey_truncate_message( $error_message );
		}

		$this->mo_sf_sync_journey_refresh_derived_state( $state );
		$this->mo_sf_sync_journey_persist( $state );
	}

	/**
	 * Marks object mapping as configured (persists flag).
	 *
	 * @param bool $configured Whether at least one mapping has been saved.
	 * @return void
	 */
	public function mo_sf_sync_journey_set_object_mapping_configured( $configured ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$state                                   = $this->mo_sf_sync_journey_get_state();
		$state['object_mapping_ever_configured'] = (bool) $configured;
		$this->mo_sf_sync_journey_persist( $state );
	}

	/**
	 * Stores real-time WP → SF provisioning toggle state.
	 *
	 * @param bool $enabled Whether automatic user/post sync is enabled.
	 * @return void
	 */
	public function mo_sf_sync_journey_set_realtime_wp_to_sf( $enabled ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$state                              = $this->mo_sf_sync_journey_get_state();
		$state['realtime_wp_to_sf_enabled'] = (bool) $enabled;
		$this->mo_sf_sync_journey_persist( $state );
	}

	/**
	 * Re-syncs derived flags from DB and options into state (by reference).
	 *
	 * @param array $state State array (updated in place).
	 * @return void
	 */
	private function mo_sf_sync_journey_refresh_derived_state( array &$state ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		if ( $this->db_utils->mo_sf_sync_has_mapping_data() ) {
			$state['object_mapping_ever_configured'] = true;
		}

		$provision = Utils::mo_sf_sync_get_settings( Plugin_Constants::PROVISION_OBJECT );
		if ( is_array( $provision ) && isset( $provision[ Plugin_Constants::AUTO_USER_UPDATE ] ) ) {
			$state['realtime_wp_to_sf_enabled'] = ( 'on' === $provision[ Plugin_Constants::AUTO_USER_UPDATE ] );
		}
	}

	/**
	 * Returns a sanitized snapshot for export or email (no secrets). All times as ISO 8601 UTC strings where applicable.
	 *
	 * @return array
	 */
	public function mo_sf_sync_journey_get_snapshot() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return array();
		}

		$state = $this->mo_sf_sync_journey_get_state();
		$this->mo_sf_sync_journey_refresh_derived_state( $state );
		$this->mo_sf_sync_journey_persist( $state );

		$recent = array();
		if ( isset( $state['recent_visits'] ) && is_array( $state['recent_visits'] ) ) {
			foreach ( $state['recent_visits'] as $row ) {
				$norm = $this->mo_sf_sync_journey_normalize_recent_visit_for_snapshot( $row );
				if ( ! empty( $norm['tab'] ) ) {
					$recent[] = $norm;
				}
			}
		}

		return array(
			'schema_version'                 => (int) $state['schema_version'],
			'tabs_visited'                   => isset( $state['tabs_visited'] ) && is_array( $state['tabs_visited'] ) ? $state['tabs_visited'] : array(),
			'last_tab'                       => isset( $state['last_tab'] ) ? sanitize_key( $state['last_tab'] ) : '',
			'last_visit_at'                  => $this->mo_sf_sync_journey_normalize_ts_for_snapshot( isset( $state['last_visit_at'] ) ? $state['last_visit_at'] : '' ),
			'recent_visits'                  => $recent,
			'connection_status'              => isset( $state['connection_status'] ) ? sanitize_key( $state['connection_status'] ) : 'never',
			'connection_last_error_code'     => isset( $state['connection_last_error_code'] ) ? sanitize_text_field( $state['connection_last_error_code'] ) : '',
			'connection_last_error_message'  => isset( $state['connection_last_error_message'] ) ? sanitize_text_field( $state['connection_last_error_message'] ) : '',
			'connection_last_attempt_at'     => $this->mo_sf_sync_journey_normalize_ts_for_snapshot( isset( $state['connection_last_attempt_at'] ) ? $state['connection_last_attempt_at'] : '' ),
			'first_successful_connection_at' => $this->mo_sf_sync_journey_normalize_first_success_snapshot( isset( $state['first_successful_connection_at'] ) ? $state['first_successful_connection_at'] : null ),
			'object_mapping_ever_configured' => ! empty( $state['object_mapping_ever_configured'] ),
			'realtime_wp_to_sf_enabled'      => ! empty( $state['realtime_wp_to_sf_enabled'] ),
		);
	}

	/**
	 * JSON snapshot for future email / tooling.
	 *
	 * @return string
	 */
	public function mo_sf_sync_journey_get_snapshot_json() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return wp_json_encode( array() );
		}

		return wp_json_encode( $this->mo_sf_sync_journey_get_snapshot() );
	}
}
