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

/**
 * Attach who a visitor turned out to be to their journey record.
 *
 * **Server-side only, and deliberately so.** The obvious implementation — read the
 * name and email in JavaScript and keep them alongside the journey in
 * localStorage — was rejected. The plugin otherwise stores no personal data at
 * all: that is what lets it say no identifying information is retained, and it is
 * why a cross-site-scripting flaw on the host site cannot leak anything about a
 * person. Putting names and emails into browser storage would reverse all of that,
 * persist them across sessions, and expose them to every other script on the
 * domain — a large change in posture for a small convenience.
 *
 * So identity is read from the form submission the server already receives, on
 * `gform_after_submission`, and never touches client storage. The only thing the
 * browser contributes is the handoff token, which it already holds.
 *
 * It is stored in dedicated columns rather than in the handoff payload, which
 * means the claim endpoint — the one that talks to a third-party origin — has no
 * code path that can reach it. That is a structural guarantee rather than a
 * setting somebody could mis-tick.
 */
class BW_Lead_AI_Identity {

	/** Hidden input the front end adds to forms so the server can find the record. */
	const TOKEN_FIELD = 'bw_lai_handoff_token';

	/** Cap on a single stored answer, and on how many answers are kept. */
	const MAX_VALUE_BYTES = 1000;
	const MAX_FIELDS      = 60;

	private static $instance = null;

	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function register() {
		if ( ! BW_Lead_AI_Settings::journey_storage_enabled() ) {
			return;
		}
		// In submission-save mode the hook has a job even with both capture
		// toggles off: promoting the anonymous journey is what makes the record
		// exist at all. In confirm-save mode the hook is only worth adding when
		// there is something to attach.
		if ( 'submission' !== BW_Lead_AI_Settings::journey_save_mode()
			&& ! BW_Lead_AI_Settings::capture_identity() && ! BW_Lead_AI_Settings::capture_submission() ) {
			return;
		}
		add_action( 'gform_after_submission', array( $this, 'on_gf_submission' ), 10, 2 );
	}

	/**
	 * @param array $entry Gravity Forms entry.
	 * @param array $form  Gravity Forms form.
	 */
	public function on_gf_submission( $entry, $form ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- reading our own hidden field from a form Gravity Forms has already processed and validated.
		$token = isset( $_POST[ self::TOKEN_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::TOKEN_FIELD ] ) ) : '';
		if ( ! BW_Lead_AI_Handoff_Store::is_token_shaped( $token ) ) {
			return;
		}

		// When journeys save on submission, THIS site is its own destination:
		// the submission is the signal the journey is worth keeping, exactly as
		// the cross-domain confirm ping is, and it goes through the identical
		// promotion — same save(), same retention, the form's title where the
		// confirm's free-text context would be. Held records that never see a
		// submission expire on their own, as before.
		if ( 'submission' === BW_Lead_AI_Settings::journey_save_mode() ) {
			$context = ( is_array( $form ) && isset( $form['title'] ) )
				? substr( sanitize_text_field( (string) $form['title'] ), 0, 200 )
				: '';
			BW_Lead_AI_Handoff_Store::save(
				$token,
				$context,
				BW_Lead_AI_Settings::handoff_retention_days(),
				BW_Lead_AI_Handoff_Store::VIA_SUBMISSION
			);
		}

		$identity   = BW_Lead_AI_Settings::capture_identity() ? $this->extract_identity( $entry, $form ) : array();
		$submission = BW_Lead_AI_Settings::capture_submission() ? $this->extract_submission( $entry, $form ) : null;

		if ( empty( $identity ) && empty( $submission ) ) {
			return;
		}
		BW_Lead_AI_Handoff_Store::attach_identity( $token, $identity, $submission );
		self::resolve_and_link( $token, $identity );
	}

	/**
	 * Turn an identity into a lead, and link the device to that person.
	 *
	 * Kept in one place so both entry points — an on-site submission and a
	 * destination's confirm ping — go through the same shared-device rule. See
	 * BW_Lead_AI_Leads::link_anchor(): if this device has already produced a
	 * different person, we flag it and stop attributing rather than guessing.
	 */
	public static function resolve_and_link( $token, $identity ) {
		if ( empty( $identity['email'] ) || ! BW_Lead_AI_Settings::capture_identity() ) {
			return 'none';
		}
		$anchor = BW_Lead_AI_Handoff_Store::get_record( $token );
		if ( ! $anchor ) {
			return 'none';
		}
		$lead_id = BW_Lead_AI_Leads::resolve( $identity );
		if ( ! $lead_id ) {
			return 'none';
		}
		return BW_Lead_AI_Leads::link_anchor( $anchor, $lead_id );
	}

	/**
	 * Pull name, email and phone out of a Gravity Forms entry.
	 *
	 * Field *types* are used first because they are reliable — a Name field is a
	 * Name field whatever it is labelled, and that also means this works on a form
	 * in any language. Label matching is only a fallback for sites that built their
	 * name and email inputs out of plain text fields.
	 */
	private function extract_identity( $entry, $form ) {
		$out    = array();
		$fields = isset( $form['fields'] ) ? $form['fields'] : array();

		foreach ( $fields as $field ) {
			$type = isset( $field->type ) ? $field->type : '';

			if ( 'email' === $type && empty( $out['email'] ) ) {
				$value = $this->entry_value( $entry, $field->id );
				if ( is_email( $value ) ) {
					$out['email'] = sanitize_email( $value );
				}
				continue;
			}

			if ( 'name' === $type && empty( $out['first'] ) && empty( $out['last'] ) ) {
				// GF's Name field splits into sub-inputs: .3 first, .6 last.
				$first = $this->entry_value( $entry, $field->id . '.3' );
				$last  = $this->entry_value( $entry, $field->id . '.6' );
				if ( '' !== $first ) { $out['first'] = sanitize_text_field( $first ); }
				if ( '' !== $last )  { $out['last'] = sanitize_text_field( $last ); }
				continue;
			}

			if ( 'phone' === $type && empty( $out['phone'] ) ) {
				$value = $this->entry_value( $entry, $field->id );
				if ( '' !== $value ) { $out['phone'] = sanitize_text_field( $value ); }
				continue;
			}
		}

		// Fallback for forms built from plain text inputs.
		foreach ( $fields as $field ) {
			$label = strtolower( isset( $field->label ) ? $field->label : '' );
			$value = $this->entry_value( $entry, isset( $field->id ) ? $field->id : '' );
			if ( '' === $value ) {
				continue;
			}
			if ( empty( $out['email'] ) && false !== strpos( $label, 'email' ) && is_email( $value ) ) {
				$out['email'] = sanitize_email( $value );
			}
			if ( empty( $out['first'] ) && ( false !== strpos( $label, 'first name' ) || 'first' === $label ) ) {
				$out['first'] = sanitize_text_field( $value );
			}
			if ( empty( $out['last'] ) && ( false !== strpos( $label, 'last name' ) || false !== strpos( $label, 'surname' ) ) ) {
				$out['last'] = sanitize_text_field( $value );
			}
		}

		foreach ( $out as $key => $value ) {
			$out[ $key ] = substr( (string) $value, 0, 200 );
		}

		/**
		 * Filter the identity extracted from a submission.
		 *
		 * @param array $out   first, last, email, phone.
		 * @param array $entry Gravity Forms entry.
		 * @param array $form  Gravity Forms form.
		 */
		return apply_filters( 'bw_lead_ai_extract_identity', $out, $entry, $form );
	}

	/**
	 * The whole submission as label/value pairs, for a glance at what they actually
	 * said. Skips the plumbing — our own hidden datapoint fields, page breaks,
	 * HTML blocks and anything empty — since a report full of blank rows and
	 * internal fields is worse than no report.
	 */
	private function extract_submission( $entry, $form ) {
		$skip   = array( 'bw_lead_ai_datapoint', 'page', 'section', 'html', 'captcha', 'honeypot' );
		$out    = array();
		$fields = isset( $form['fields'] ) ? $form['fields'] : array();

		foreach ( $fields as $field ) {
			if ( count( $out ) >= self::MAX_FIELDS ) {
				break;
			}
			$type = isset( $field->type ) ? $field->type : '';
			if ( in_array( $type, $skip, true ) ) {
				continue;
			}
			if ( ! empty( $field->adminOnly ) ) {
				continue;
			}

			$label = isset( $field->label ) ? sanitize_text_field( $field->label ) : '';
			$value = '';

			// Composite fields (name, address) render as their assembled value.
			if ( is_callable( array( $field, 'get_value_export' ) ) ) {
				$value = (string) $field->get_value_export( $entry, '', true, false );
			}
			if ( '' === $value ) {
				$value = $this->entry_value( $entry, isset( $field->id ) ? $field->id : '' );
			}
			if ( '' === trim( $value ) ) {
				continue;
			}

			$out[] = array(
				'label' => $label,
				'value' => substr( sanitize_textarea_field( $value ), 0, self::MAX_VALUE_BYTES ),
			);
		}

		/**
		 * Filter the captured submission before it is stored.
		 *
		 * The obvious use is dropping a sensitive question that should never be
		 * retained alongside a browsing history.
		 *
		 * @param array $out   List of label/value pairs.
		 * @param array $entry Gravity Forms entry.
		 * @param array $form  Gravity Forms form.
		 */
		return apply_filters( 'bw_lead_ai_capture_submission', $out, $entry, $form );
	}

	private function entry_value( $entry, $key ) {
		if ( '' === $key || ! isset( $entry[ (string) $key ] ) ) {
			return '';
		}
		return (string) $entry[ (string) $key ];
	}

	/**
	 * A display name for a stored identity, or '' if we never learned one.
	 */
	public static function display_name( $identity ) {
		if ( empty( $identity ) || ! is_array( $identity ) ) {
			return '';
		}
		$name = trim( ( isset( $identity['first'] ) ? $identity['first'] : '' ) . ' ' . ( isset( $identity['last'] ) ? $identity['last'] : '' ) );
		if ( '' !== $name ) {
			return $name;
		}
		return isset( $identity['email'] ) ? $identity['email'] : '';
	}
}
