<?php
/**
 * Tracker: front-end beacon enqueue + the public collection endpoint.
 *
 * This is the ONLY public (unauthenticated) endpoint in the plugin — it has to
 * be, because anonymous visitors send hits. It is therefore strictly validated,
 * same-origin checked, rate-limited, and derives all sensitive data server-side.
 *
 * @package Defog
 */

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

class Defog_Tracker {

	const SESSION_GAP = 1800; // 30 minutes (seconds) → new session boundary.

	public function __construct() {
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'rest_api_init', array( $this, 'register_route' ) );
	}

	/**
	 * Enqueue the tiny tracking beacon, unless the visitor is excluded.
	 */
	public function enqueue() {
		if ( $this->is_excluded_viewer() ) {
			return;
		}

		wp_register_script( 'defog-tracker', DEFOG_URL . 'assets/js/defog-tracker.js', array(), DEFOG_VERSION, true );
		wp_localize_script(
			'defog-tracker',
			'DefogTracker',
			array(
				'endpoint' => esc_url_raw( rest_url( 'defog/v1/hit' ) ),
				'dnt'      => (int) defog_setting( 'respect_dnt', 0 ),
				'ev'       => (int) defog_setting( 'track_events', 1 ),
				'pt'       => $this->page_type(),
			)
		);
		wp_enqueue_script( 'defog-tracker' );
	}

	/**
	 * Detect the WooCommerce page context for funnel tracking.
	 *
	 * @return string product|checkout|other
	 */
	private function page_type() {
		if ( function_exists( 'is_product' ) && is_product() ) {
			return 'product';
		}
		if ( function_exists( 'is_checkout' ) && is_checkout()
			&& ( ! function_exists( 'is_order_received_page' ) || ! is_order_received_page() ) ) {
			return 'checkout';
		}
		return 'other';
	}

	/**
	 * Visitors we never track: excluded logged-in roles (admins/editors).
	 *
	 * @return bool
	 */
	private function is_excluded_viewer() {
		if ( defog_setting( 'exclude_logged_in', 1 ) && is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Register the public collection route.
	 */
	public function register_route() {
		register_rest_route(
			'defog/v1',
			'/hit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'collect' ),
				'permission_callback' => '__return_true', // Public by necessity; hardened below.
			)
		);
	}

	/**
	 * Handle an incoming pageview.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response
	 */
	public function collect( WP_REST_Request $request ) {
		// Always answer 204-ish so beacons never retry / leak timing.
		$noop = new WP_REST_Response( null, 204 );

		// 1) Same-origin guard: the Origin/Referer host must match the site host.
		if ( ! $this->is_same_origin() ) {
			return $noop;
		}

		$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] )
			? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
			: '';

		// 2) Bot / prefetch filtering.
		if ( Defog_Bot_Filter::is_bot( $user_agent ) ) {
			return $noop;
		}

		// 3) Inputs (sanitised; sensitive values are derived server-side, not trusted).
		$path = (string) $request->get_param( 'p' );
		$path = '/' . ltrim( wp_parse_url( $path, PHP_URL_PATH ) ?: '/', '/' );
		$path = substr( sanitize_text_field( $path ), 0, 190 );

		$ref = (string) $request->get_param( 'r' );
		$ref_host = $ref ? wp_parse_url( $ref, PHP_URL_HOST ) : '';
		$ref_host = $ref_host ? substr( sanitize_text_field( $ref_host ), 0, 190 ) : '';

		$utm_source = substr( sanitize_text_field( (string) $request->get_param( 'us' ) ), 0, 60 );
		$utm_medium = substr( sanitize_text_field( (string) $request->get_param( 'um' ) ), 0, 60 );
		$ad         = substr( sanitize_text_field( (string) $request->get_param( 'ad' ) ), 0, 20 );

		// 4) Cookieless identity (IP used transiently only).
		$ip      = Defog_Privacy::client_ip();
		$visitor = Defog_Privacy::visitor_hash( $ip, $user_agent );

		global $wpdb;
		$events = defog_table( 'events' );

		// Engagement ping: attach time-on-page to the visitor's latest pageview
		// of this path, instead of recording a new pageview.
		$dur = $request->get_param( 'dur' );
		if ( null !== $dur && '' !== $dur ) {
			$dur = (int) $dur;
			if ( $dur > 0 && $dur < 3600 ) {
				$wpdb->query(
					$wpdb->prepare(
						"UPDATE %i SET engaged = %d WHERE visitor_hash = %s AND path = %s ORDER BY occurred_at DESC LIMIT 1",
					$events,
						$dur,
						$visitor,
						$path
					)
				);
			}
			return $noop;
		}

		// 4b) Event / action hit (e.g. an outbound link click). Recorded in its own
		// table and never counted as a pageview. `p` carries the page it happened on,
		// `el` the destination label. Categories are whitelisted and extensible.
		if ( 'e' === sanitize_key( (string) $request->get_param( 't' ) ) ) {
			if ( ! defog_setting( 'track_events', 1 ) ) {
				return $noop;
			}
			$category = sanitize_key( (string) $request->get_param( 'ec' ) );
			if ( ! in_array( $category, array( 'outbound', 'download', 'form' ), true ) ) {
				return $noop;
			}
			$label = substr( sanitize_text_field( (string) $request->get_param( 'el' ) ), 0, 190 );
			if ( '' === $label ) {
				return $noop;
			}
			// Dedupe rapid duplicate fires of the same action. Checked against the
			// actions table itself rather than transients: this endpoint is public,
			// so per-visitor transient keys would let anyone bloat wp_options.
			$actions = defog_table( 'actions' );
			$recent  = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT 1 FROM %i WHERE occurred_at >= %s AND visitor_hash = %s AND category = %s AND label = %s LIMIT 1",
					$actions,
					$this->seconds_ago( 2 ),
					$visitor,
					$category,
					$label
				)
			);
			if ( $recent ) {
				return $noop;
			}

			$wpdb->insert(
				defog_table( 'actions' ),
				array(
					'occurred_at'  => defog_now(),
					'day'          => defog_today(),
					'visitor_hash' => $visitor,
					'category'     => $category,
					'label'        => $label,
					'path'         => $path,
				),
				array( '%s', '%s', '%s', '%s', '%s', '%s' )
			);
			return $noop;
		}

		// 5) Rate limit: dedupe rapid duplicate fires of the same path. Uses the
		// events table (indexed on visitor_hash) instead of transients — this
		// endpoint is public, so unique transient keys would bloat wp_options.
		$recent = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT 1 FROM %i WHERE visitor_hash = %s AND path = %s AND occurred_at >= %s LIMIT 1",
				$events,
				$visitor,
				$path,
				$this->seconds_ago( 2 )
			)
		);
		if ( $recent ) {
			return $noop;
		}

		// 6) Derive dimensions.
		$source   = $this->classify_source( $ref_host, $utm_source, $utm_medium, $ad );
		$device   = $this->detect_device( $user_agent );
		$browser  = $this->detect_browser( $user_agent );
		$os       = $this->detect_os( $user_agent );
		$country  = Defog_Privacy::country();

		// 7) New visitor / new session flags (today + 30-min window).
		list( $is_new_visitor, $is_new_session ) = $this->visitor_flags( $visitor );

		// 8) Exit tracking: within a continuing session, the previous pageview is
		// no longer the exit. New pageview is provisionally the exit.
		if ( ! $is_new_session ) {
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE %i SET is_exit = 0 WHERE visitor_hash = %s AND is_exit = 1 ORDER BY occurred_at DESC LIMIT 1",
					$events,
					$visitor
				)
			);
		}

		// 9) Insert.
		$wpdb->insert(
			defog_table( 'events' ),
			array(
				'occurred_at'    => defog_now(),
				'visitor_hash'   => $visitor,
				'is_new_visitor' => $is_new_visitor,
				'is_new_session' => $is_new_session,
				'path'           => $path,
				'source'         => $source,
				'referrer_host'  => $ref_host ? $ref_host : null,
				'country'        => $country,
				'device'         => $device,
				'browser'        => $browser,
				'os'             => $os,
				'is_exit'        => 1,
			),
			array( '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' )
		);

		// Funnel signal for product / checkout page views.
		$pt = sanitize_key( (string) $request->get_param( 'pt' ) );
		if ( 'product' === $pt || 'checkout' === $pt ) {
			defog_record_funnel( $visitor, $pt );
		}

		return $noop;
	}

	/**
	 * Local datetime N seconds ago, matching how occurred_at is stored.
	 *
	 * @param int $seconds Seconds to subtract.
	 * @return string
	 */
	private function seconds_ago( $seconds ) {
		return gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - (int) $seconds ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp
	}

	/**
	 * Require the request to originate from this site.
	 *
	 * @return bool
	 */
	private function is_same_origin() {
		$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
		foreach ( array( 'HTTP_ORIGIN', 'HTTP_REFERER' ) as $key ) {
			if ( empty( $_SERVER[ $key ] ) ) {
				continue;
			}
			$host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER[ $key ] ) ), PHP_URL_HOST );
			if ( $host && strtolower( $host ) === strtolower( (string) $site_host ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Determine new-visitor (per local day) and new-session (30-min gap) flags.
	 *
	 * @param string $visitor Visitor hash.
	 * @return array{0:int,1:int}
	 */
	private function visitor_flags( $visitor ) {
		global $wpdb;
		$events = defog_table( 'events' );

		// Last time we saw this visitor today.
		$last = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT occurred_at FROM %i WHERE visitor_hash = %s ORDER BY occurred_at DESC LIMIT 1",
					$events,
				$visitor
			)
		);

		if ( ! $last ) {
			return array( 1, 1 ); // First sighting (within the daily salt window).
		}

		$gap            = time() - strtotime( $last );
		$is_new_session = ( $gap > self::SESSION_GAP ) ? 1 : 0;

		// New visitor only if no event earlier *today*.
		$seen_today = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT 1 FROM %i WHERE visitor_hash = %s AND occurred_at >= %s LIMIT 1",
					$events,
				$visitor,
				defog_today() . ' 00:00:00'
			)
		);
		$is_new_visitor = $seen_today ? 0 : 1;

		return array( $is_new_visitor, $is_new_session );
	}

	/**
	 * Classify the acquisition channel.
	 *
	 * @param string $ref_host   Referrer host.
	 * @param string $utm_source UTM source.
	 * @param string $utm_medium UTM medium.
	 * @param string $ad         Detected ad platform (google|microsoft|'').
	 * @return string direct|organic|social|referral|ai|paid
	 */
	private function classify_source( $ref_host, $utm_source, $utm_medium, $ad = '' ) {
		$medium = strtolower( $utm_medium );
		$src    = strtolower( $utm_source );
		$host   = strtolower( (string) $ref_host );

		// Is there a paid signal? Ad click-ids (auto-tagging) or a paid medium.
		$is_paid = in_array( $ad, array( 'google', 'microsoft' ), true )
			|| ( $medium && preg_match( '/^(cpc|ppc|paid|paidsearch|paid-search|paid_search|paidsocial|paid-social|paid_social|display|cpm|shopping|retargeting|remarketing)/', $medium ) );

		// Is the visit coming from a social platform (by utm_source or referrer)?
		$is_social = $this->is_social_source( $src, $host );

		// Split paid into search/shopping vs social.
		if ( $is_paid && $is_social ) {
			return 'paid_social';
		}
		if ( $is_paid ) {
			return 'paid';
		}

		// Explicit (non-paid) medium hints.
		if ( in_array( $medium, array( 'organic', 'search' ), true ) ) {
			return 'organic';
		}
		if ( in_array( $medium, array( 'social', 'social-network', 'sm' ), true ) ) {
			return 'social';
		}
		if ( in_array( $medium, array( 'referral', 'affiliate' ), true ) ) {
			return 'referral';
		}

		$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		if ( '' === $host || $host === $site_host ) {
			return 'direct';
		}

		// Organic social (no paid signal).
		if ( $is_social ) {
			return 'social';
		}

		$map = array(
			'organic' => array( 'google.', 'bing.', 'yahoo.', 'duckduckgo.', 'yandex.', 'baidu.', 'ecosia.', 'seznam.', 'centrum.', 'startpage.' ),
			'ai'      => array( 'chatgpt.com', 'chat.openai.com', 'openai.com', 'perplexity.ai', 'gemini.google', 'claude.ai', 'copilot.microsoft', 'bard.google' ),
		);
		foreach ( $map as $type => $needles ) {
			foreach ( $needles as $needle ) {
				if ( false !== strpos( $host, $needle ) ) {
					return $type;
				}
			}
		}

		return 'referral';
	}

	/**
	 * Whether a visit originates from a social platform (utm_source or referrer).
	 *
	 * @param string $src  Lowercased utm_source.
	 * @param string $host Lowercased referrer host.
	 * @return bool
	 */
	private function is_social_source( $src, $host ) {
		$keys = array( 'facebook', 'instagram', 'linkedin', 'twitter', 'reddit', 'youtube', 'pinterest', 'tiktok', 'threads', 'mastodon', 'fb', 'ig' );
		if ( $src ) {
			foreach ( $keys as $k ) {
				if ( false !== strpos( $src, $k ) ) {
					return true;
				}
			}
		}
		$hosts = array( 'facebook.', 'fb.', 'instagram.', 't.co', 'twitter.', 'x.com', 'linkedin.', 'lnkd.in', 'reddit.', 'youtube.', 'youtu.be', 'pinterest.', 'tiktok.', 'mastodon.', 'threads.' );
		if ( $host ) {
			foreach ( $hosts as $h ) {
				if ( false !== strpos( $host, $h ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Detect device class from the UA.
	 *
	 * @param string $ua User-agent.
	 * @return string mobile|tablet|desktop
	 */
	private function detect_device( $ua ) {
		if ( preg_match( '/ipad|tablet|playbook|silk|kindle/i', $ua )
			|| ( preg_match( '/android/i', $ua ) && ! preg_match( '/mobile/i', $ua ) ) ) {
			return 'tablet';
		}
		if ( preg_match( '/mobile|iphone|ipod|android|blackberry|iemobile|opera mini|windows phone/i', $ua ) ) {
			return 'mobile';
		}
		return 'desktop';
	}

	/**
	 * Detect browser family from the UA.
	 *
	 * @param string $ua User-agent.
	 * @return string
	 */
	private function detect_browser( $ua ) {
		if ( preg_match( '/Edg/i', $ua ) ) { return 'Edge'; }
		if ( preg_match( '/OPR|Opera/i', $ua ) ) { return 'Opera'; }
		if ( preg_match( '/SamsungBrowser/i', $ua ) ) { return 'Samsung'; }
		if ( preg_match( '/Firefox/i', $ua ) ) { return 'Firefox'; }
		if ( preg_match( '/Chrome|CriOS/i', $ua ) ) { return 'Chrome'; }
		if ( preg_match( '/Safari/i', $ua ) ) { return 'Safari'; }
		return 'Other';
	}

	/**
	 * Detect OS family from the UA.
	 *
	 * @param string $ua User-agent.
	 * @return string
	 */
	private function detect_os( $ua ) {
		if ( preg_match( '/Windows/i', $ua ) ) { return 'Windows'; }
		if ( preg_match( '/iPhone|iPad|iPod|iOS/i', $ua ) ) { return 'iOS'; }
		if ( preg_match( '/Mac OS X|Macintosh/i', $ua ) ) { return 'macOS'; }
		if ( preg_match( '/Android/i', $ua ) ) { return 'Android'; }
		if ( preg_match( '/Linux/i', $ua ) ) { return 'Linux'; }
		return 'Other';
	}
}
