<?php

namespace Limb_Chatbot\Includes\Services;

use Exception;
use Limb_Chatbot\Includes\Data_Objects\Chatbot_User;
use Limb_Chatbot\Includes\Data_Objects\Setting;
use Limb_Chatbot\Includes\Repositories\Chatbot_User_Repository;
use Limb_Chatbot\Includes\Services\Channel_Events\Channel_Event_Bootstrap;
use Limb_Chatbot\Includes\Services\Channel_Events\Channel_Event_Types;
use Limb_Chatbot\Includes\Traits\SingletonTrait;

/**
 * Class User_Manager
 *
 * Manages the current chatbot user session and identification.
 *
 * @since 1.0.0
 */
class User_Manager {

	use SingletonTrait;

	/**
	 * Identification by device UUID.
	 *
	 * @since 1.0.0
	 */
	const IDENTIFICATION_METHOD_DEVICE_UUID = 1;

	/**
	 * Identification by IP address.
	 *
	 * @since 1.0.0
	 */
	const IDENTIFICATION_METHOD_IP = 2;

	/**
	 * Identification by both device UUID and IP address.
	 *
	 * @since 1.0.0
	 */
	const IDENTIFICATION_METHOD_BOTH = 3;

	/**
	 * Setting key for identification method.
	 *
	 * @since 1.0.0
	 */
	const IDENTIFICATION_METHOD_SETTING_KEY = 'guest_user_identification_method';

	/**
	 * Currently identified chatbot user.
	 *
	 * @var Chatbot_User|null
	 * @since 1.0.0
	 */
	public ?Chatbot_User $current_user = null;

	/**
	 * Gets the current chatbot user.
	 *
	 * @return Chatbot_User|null
	 * @throws Exception
	 * @since 1.0.0
	 */
	public function get_current_user(): ?Chatbot_User {
		if ( empty( $this->current_user ) ) {
			$this->define_current_user();
		}

		return $this->current_user;
	}

	/**
	 * Sets the current chatbot user.
	 *
	 * @param  Chatbot_User|null  $current_user
	 *
	 * @return void
	 * @since 1.0.0
	 */
	public function set_current_user( ?Chatbot_User $current_user ): void {
		$this->current_user = $current_user;
		Session_Manager::instance()->set_cookie( 'chatbot_user_uuid', $current_user->get_uuid() );
	}

	/**
	 * Gets the client IP address, respecting Cloudflare and other reverse proxies.
	 *
	 * When behind Cloudflare, REMOTE_ADDR is the edge IP; the real client IP is in
	 * CF-Connecting-IP. Also checks X-Forwarded-For and X-Real-IP for other proxies.
	 *
	 * @return string Valid IPv4 or IPv6 address, or '0.0.0.0' if none could be determined.
	 * @since 1.0.0
	 */
	private function get_client_ip(): string {
		$forwarded       = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null;
		$xff_candidates  = array();
		if ( $forwarded !== null && $forwarded !== '' ) {
			$xff_candidates = array_filter( array_map( 'trim', explode( ',', (string) $forwarded ) ) );
		}

		// Prefer Cloudflare (trusted when behind CF), then X-Real-IP, then X-Forwarded-For chain, then REMOTE_ADDR.
		$candidates = array_filter( array_merge( [
			$_SERVER['HTTP_CF_CONNECTING_IP'] ?? null,
			$_SERVER['HTTP_X_REAL_IP'] ?? null,
		], $xff_candidates, [
			$_SERVER['REMOTE_ADDR'] ?? null,
		] ) );

		foreach ( array_unique( $candidates ) as $value ) {
			if ( $value === null || $value === '' ) {
				continue;
			}
			$cleaned = sanitize_text_field( (string) $value );
			$cleaned = $this->strip_ip_port_suffix( $cleaned );
			if ( $cleaned !== '' && filter_var( $cleaned, FILTER_VALIDATE_IP ) ) {
				return $this->normalize_ip( $cleaned );
			}
		}

		return '0.0.0.0';
	}

	/**
	 * Removes optional port suffix from raw IP header values.
	 *
	 * Supports values like:
	 * - 1.2.3.4:1234
	 * - [2001:db8::1]:443
	 * - 2001:db8::1
	 *
	 * @param  string  $value  Raw header/server value.
	 * @return string
	 * @since 1.0.0
	 */
	private function strip_ip_port_suffix( string $value ): string {
		$value = trim( $value );
		// IPv6 in square brackets, optionally with port.
		if ( preg_match( '/^\[([0-9a-fA-F:]+)\](?::\d+)?$/', $value, $matches ) ) {
			return $matches[1];
		}
		// IPv4 with port.
		if ( preg_match( '/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/', $value, $matches ) ) {
			return $matches[1];
		}

		return $value;
	}

	/**
	 * Normalizes IP values while preserving geolocatable public addresses.
	 *
	 * Converts ::1 to 127.0.0.1 and IPv4-mapped IPv6 (::ffff:x.x.x.x) to x.x.x.x.
	 * Keeps public IPv6 untouched instead of downgrading to 0.0.0.0.
	 *
	 * @param  string  $ip  Valid IPv4 or IPv6 address.
	 * @return string
	 */
	private function normalize_ip( string $ip ): string {
		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return $ip;
		}
		if ( $ip === '::1' ) {
			return '127.0.0.1';
		}
		if ( substr( $ip, 0, 7 ) === '::ffff:' ) {
			$mapped = substr( $ip, 7 );
			if ( filter_var( $mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
				return $mapped;
			}
		}

		return $ip;
	}

	/**
	 * Defines the current chatbot user based on identification method.
	 *
	 * @return void
	 * @throws Exception
	 * @since 1.0.0
	 */
	public function define_current_user() {
		$ip_address            = $this->get_client_ip();
		$identification_method = Setting::find( self::IDENTIFICATION_METHOD_SETTING_KEY )->get_value() ?? self::IDENTIFICATION_METHOD_DEVICE_UUID;
		$device_uuid           = Session_Manager::instance()->get_device_uuid();
		if ( empty( $device_uuid ) ) {
			$device_uuid = Helper::get_uuid();
			Session_Manager::instance()->set_cookie( 'device_uuid', $device_uuid );
		}

		// Collect device information
		$device_info = $this->collect_device_info();

		if ( $wp_user_id = get_current_user_id() ) {
			$current_user = Chatbot_User::where( [
				'wp_user_id'  => $wp_user_id,
				'device_uuid' => $device_uuid
			] )->first();
			if ( ! $current_user ) {
				$current_user = ( new Chatbot_User_Repository() )->create( $wp_user_id,
					$ip_address,
					$device_uuid,
					Chatbot_User::TYPE_WP_USER );
				// Store device information in meta for new user
				$this->store_device_info( $current_user, $device_info );

				$this->dispatch_admin_new_chatbot_user_event( $current_user, (int) $wp_user_id, $device_uuid );
			}
		} else {
			$where = [];
			if ( $identification_method === self::IDENTIFICATION_METHOD_DEVICE_UUID ) {
				$where['device_uuid'] = $device_uuid;
			} elseif ( $identification_method === self::IDENTIFICATION_METHOD_IP ) {
				$where['ip'] = $ip_address;
			} elseif ( $identification_method === self::IDENTIFICATION_METHOD_BOTH ) {
				$where['device_uuid'] = $device_uuid;
				$where['ip']          = $ip_address;
			}
			$where['wp_user_id'] = null;
			$current_user        = Chatbot_User::where( $where )->first();
			if ( ! $current_user ) {
				$current_user = ( new Chatbot_User_Repository() )->create( null,
					$ip_address,
					$device_uuid,
					Chatbot_User::TYPE_GUEST );
				// Store device information in meta for new user
				$this->store_device_info( $current_user, $device_info );

				$this->dispatch_admin_new_chatbot_user_event( $current_user, null, $device_uuid );
			}
		}
		$this->maybe_update_last_visited_page( $current_user );
		$current_user->touch();
		$this->set_current_user( $current_user );
	}

	/**
	 * Dispatch "new chatbot user" event rows to *website administrators only*.
	 *
	 * No email is sent from this path; it exists solely for event registration.
	 *
	 * @param  Chatbot_User  $chatbot_user
	 *
	 * @return void
	 */
	private function dispatch_admin_new_chatbot_user_event(
		Chatbot_User $chatbot_user
	): void {
		try {
			$admin_wp_user_ids = $this->get_admin_wp_user_ids();
			if ( empty( $admin_wp_user_ids ) ) {
				return;
			}

			$chatbot_user_uuid = $chatbot_user->uuid;

			Channel_Event_Bootstrap::get_dispatcher()->dispatch(
				Channel_Event_Types::ADMIN_NEW_CHATBOT_USER,
				$admin_wp_user_ids,
				null,
				$chatbot_user_uuid,
			);
		} catch ( \Throwable $e ) {
			// Event registration must never break user creation.
			// Use existing logging helper when available.
			if ( class_exists( Helper::class ) ) {
				Helper::log( $e, __METHOD__ );
			}
		}
	}

	/**
	 * Return WordPress user IDs for website admins only.
	 *
	 * @return int[]
	 */
	private function get_admin_wp_user_ids(): array {
		$admin_ids = get_users(
			[
				'role__in' => [ 'administrator', 'super_admin' ],
				'fields'   => 'ID',
			]
		);

		$admin_ids = array_map( 'absint', (array) $admin_ids );
		$admin_ids = array_values( array_unique( array_filter( $admin_ids ) ) );

		return $admin_ids;
	}

	/**
	 * Collects device information from server variables.
	 *
	 * @return array Device information array.
	 * @since 1.0.0
	 */
	private function collect_device_info(): array {
		// Delegate detection logic to the dedicated detector class.
		$detector = new Device_Info_Detector();

		return $detector->collect_device_info();
	}

	/**
	 * Stores device information in user meta.
	 *
	 * @param  Chatbot_User  $user         Chatbot user instance.
	 * @param  array         $device_info  Device information array.
	 *
	 * @return void
	 * @since 1.0.0
	 */
	private function store_device_info( Chatbot_User $user, array $device_info ): void {
		// Store complete device info as JSON in meta
		$user->update_meta( 'device_info', wp_json_encode( $device_info ) );
	}

	/**
	 * Persists the visitor-facing URL path for the current request (e.g. /pricing).
	 *
	 * Uses REQUEST_URI for normal front-end requests. For internal WordPress endpoints
	 * (REST, admin-ajax, wp-cron, etc.) falls back to a same-origin HTTP Referer path
	 * so chat API calls from a page do not overwrite the path with /wp-json/...
	 *
	 * @param  Chatbot_User  $user
	 *
	 * @return void
	 */
	private function maybe_update_last_visited_page( Chatbot_User $user ): void {
		$path = $this->resolve_last_visited_page_path();
		if ( $path === null ) {
			return;
		}
		$existing = $user->get_meta( Chatbot_User::META_KEY_LAST_VISITED_PAGE );
		if ( is_string( $existing ) && $existing === $path ) {
			return;
		}
		$user->update_meta( Chatbot_User::META_KEY_LAST_VISITED_PAGE, $path );
	}

	/**
	 * @return string|null Normalized path, or null if none should be stored.
	 */
	private function resolve_last_visited_page_path(): ?string {
		if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {
			return null;
		}

		// REST API path varies with subdirectory installs; rely on same-origin Referer for page context.
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return $this->last_visited_path_from_same_origin_referer();
		}

		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( (string) $_SERVER['REQUEST_URI'] ) : '';
		$uri_path    = wp_parse_url( $request_uri, PHP_URL_PATH );
		if ( ! is_string( $uri_path ) || $uri_path === '' ) {
			$uri_path = '/';
		}
		$uri_path = $this->normalize_request_path_component( $uri_path );
		if ( $uri_path === null ) {
			return null;
		}

		if ( $this->is_internal_wordpress_request_path( $uri_path ) ) {
			return $this->last_visited_path_from_same_origin_referer();
		}

		return $this->finalize_public_path( $uri_path );
	}

	/**
	 * @param  string  $path  Path from wp_parse_url (may be unnormalized).
	 *
	 * @return string|null
	 */
	private function normalize_request_path_component( string $path ): ?string {
		$path = wp_normalize_path( rawurldecode( $path ) );
		if ( strpos( $path, '..' ) !== false ) {
			return null;
		}
		$path = '/' . ltrim( $path, '/' );
		if ( strlen( $path ) > 512 ) {
			return null;
		}

		return $path;
	}

	/**
	 * @param  string  $path  Normalized path starting with /.
	 *
	 * @return string|null
	 */
	private function finalize_public_path( string $path ): ?string {
		if ( $path !== '/' ) {
			$path = untrailingslashit( $path );
		}
		if ( $path === '' ) {
			$path = '/';
		}

		return $path;
	}

	/**
	 * Paths that are not useful as "visitor page" for chat context.
	 *
	 * @param  string  $path  Lowercasing not required.
	 */
	private function is_internal_wordpress_request_path( string $path ): bool {
		$lower = strtolower( $path );
		$internal_prefixes = [
			'/wp-json/',
			'/wp-admin/',
			'/wp-includes/',
			'/wp-content/',
		];
		foreach ( $internal_prefixes as $prefix ) {
			if ( strpos( $lower, $prefix ) === 0 ) {
				return true;
			}
		}
		if ( $lower === '/wp-cron.php' || $lower === '/xmlrpc.php' ) {
			return true;
		}
		$basename = strtolower( basename( $path ) );

		return $basename === 'admin-ajax.php';
	}

	/**
	 * @return string|null
	 */
	private function last_visited_path_from_same_origin_referer(): ?string {
		if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
			return null;
		}
		$referer = esc_url_raw( wp_unslash( (string) $_SERVER['HTTP_REFERER'] ) );
		if ( $referer === '' ) {
			return null;
		}
		$referer_host = wp_parse_url( $referer, PHP_URL_HOST );
		$home_host    = wp_parse_url( home_url(), PHP_URL_HOST );
		if ( ! is_string( $referer_host ) || $referer_host === '' || ! is_string( $home_host ) || $home_host === '' ) {
			return null;
		}
		if ( strtolower( $referer_host ) !== strtolower( $home_host ) ) {
			return null;
		}
		$ref_path = wp_parse_url( $referer, PHP_URL_PATH );
		if ( ! is_string( $ref_path ) || $ref_path === '' ) {
			$ref_path = '/';
		}
		$ref_path = $this->normalize_request_path_component( $ref_path );
		if ( $ref_path === null || $this->is_internal_wordpress_request_path( $ref_path ) ) {
			return null;
		}

		return $this->finalize_public_path( $ref_path );
	}

	/**
	 * Get chatbot users that correspond to WordPress admin / super admin users.
	 *
	 * Finds WP users with administrator or super_admin role, then returns Chatbot_User
	 * records linked to those wp_user_ids.
	 *
	 * @return Chatbot_User[] Array of Chatbot_User instances (may be empty).
	 * @since 1.0.15
	 */
	public function get_admins(): array {
		$admin_ids = get_users( [
			'role__in' => [ 'administrator', 'super_admin' ],
			'fields'   => 'ID',
		] );

		$admin_ids = array_map( 'absint', (array) $admin_ids );
		$admin_ids = array_filter( array_unique( $admin_ids ) );

		if ( empty( $admin_ids ) ) {
			return [];
		}

		$collection = Chatbot_User::where( [ 'wp_user_id' => $admin_ids ], - 1, - 1, 'created_at', 'DESC' );
		$items      = $collection->get() ?? [];

		// Return only one Chatbot_User per wp_user_id (e.g. same admin on multiple devices).
		$seen_wp_user_ids = [];

		return array_values( array_filter( $items, function ( Chatbot_User $user ) use ( &$seen_wp_user_ids ) {
			$wp_id = $user->get_wp_user_id();
			if ( $wp_id === null ) {
				return true;
			}
			if ( isset( $seen_wp_user_ids[ $wp_id ] ) ) {
				return false;
			}
			$seen_wp_user_ids[ $wp_id ] = true;

			return true;
		} ) );
	}
}
