<?php

declare( strict_types=1 );

namespace StrykeWP\Modules\Connector;

/**
 * Audit Logger for the Hub Connector module.
 *
 * Ported from StrykeHub_Logger (stryke-connector v1.0.0).
 * Table name, cron hook, and DB version option preserved for backward compat.
 *
 * @package StrykeWP\Modules\Connector
 */
class ApiLogger {

	// -----------------------------------------------------------------------
	// Constants
	// -----------------------------------------------------------------------

	/** WP-Cron hook — kept for backward compat with existing schedules. */
	const CRON_HOOK = 'stryke_hub_prune_logs';

	/** Number of days log entries are retained before pruning. */
	const RETENTION_DAYS = 30;

	/** Default entries returned by get_recent_logs(). */
	const DEFAULT_LIMIT = 50;

	/** Maximum entries requestable via ?limit= param. */
	const MAX_LIMIT = 200;

	/** Maximum rows in the log table before oldest entries are pruned on write. */
	const MAX_LOG_ROWS = 10000;

	/** Schema version stored in wp_options. */
	const DB_VERSION = '1.0';

	/** wp_options key for schema version — kept for backward compat. */
	const DB_VERSION_OPTION = 'stryke_hub_db_version';

	// -----------------------------------------------------------------------
	// Schema Management
	// -----------------------------------------------------------------------

	/**
	 * Returns the fully-qualified log table name (e.g. `wp_stryke_logs`).
	 *
	 * @return string
	 */
	public static function get_table_name(): string {
		global $wpdb;
		return $wpdb->prefix . 'stryke_logs';
	}

	/**
	 * Creates (or upgrades) the log table using dbDelta().
	 * Must be called from on_enable() / register_activation_hook() ONLY.
	 *
	 * @return void
	 */
	public static function create_table(): void {
		global $wpdb;

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table_name      = self::get_table_name();
		$charset_collate = $wpdb->get_charset_collate();

		// IMPORTANT: dbDelta() is whitespace-sensitive.
		$sql = "CREATE TABLE {$table_name} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  timestamp datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  endpoint varchar(255) NOT NULL DEFAULT '',
  method varchar(10) NOT NULL DEFAULT '',
  status_code smallint(5) unsigned NOT NULL DEFAULT 0,
  ip_address varchar(45) NOT NULL DEFAULT '',
  user_agent text DEFAULT NULL,
  PRIMARY KEY  (id),
  KEY idx_timestamp (timestamp)
) ENGINE=InnoDB {$charset_collate};";

		dbDelta( $sql );
		update_option( self::DB_VERSION_OPTION, self::DB_VERSION, false );
	}

	// -----------------------------------------------------------------------
	// Write
	// -----------------------------------------------------------------------

	/**
	 * REST filter callback: `rest_post_dispatch` (priority 99, 3 args).
	 * Logs every stryke/v1 REST request after the response is built.
	 *
	 * @param \WP_REST_Response $response The outgoing REST response.
	 * @param \WP_REST_Server   $server   The REST server instance (unused).
	 * @param \WP_REST_Request  $request  The original REST request.
	 * @return \WP_REST_Response The response, passed through unmodified.
	 */
	public function log_rest_request(
		\WP_REST_Response $response,
		\WP_REST_Server   $server,
		\WP_REST_Request  $request
	): \WP_REST_Response {

		$route = $request->get_route();

		if ( ! str_starts_with( $route, '/stryke/v1' ) ) {
			return $response;
		}

		$this->write_log(
			$route,
			$request->get_method(),
			$response->get_status()
		);

		return $response;
	}

	/**
	 * Performs the actual DB INSERT for a single log entry.
	 *
	 * @param string $endpoint    The matched REST route.
	 * @param string $method      HTTP verb.
	 * @param int    $status_code Final HTTP status code.
	 * @return void
	 */
	private function write_log( string $endpoint, string $method, int $status_code ): void {
		global $wpdb;

		$table = self::get_table_name();

		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$table,
			array(
				'endpoint'    => substr( $endpoint, 0, 255 ),
				'method'      => substr( strtoupper( $method ), 0, 10 ),
				'status_code' => $status_code,
				'ip_address'  => $this->get_client_ip(),
				'user_agent'  => $this->get_user_agent(),
			),
			array( '%s', '%s', '%d', '%s', '%s' )
		);

		// Prune excess rows to prevent unbounded table growth.
		$count = (int) $wpdb->get_var( "SELECT COUNT(id) FROM {$table}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
		if ( $count > self::MAX_LOG_ROWS ) {
			$excess = $count - self::MAX_LOG_ROWS;
			$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} ORDER BY id ASC LIMIT %d", $excess ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
		}
	}

	// -----------------------------------------------------------------------
	// Read
	// -----------------------------------------------------------------------

	/**
	 * Returns the most recent log entries, newest first.
	 *
	 * @param int $limit Number of entries to retrieve.
	 * @return array<int, array<string, mixed>>
	 */
	public function get_recent_logs( int $limit = self::DEFAULT_LIMIT ): array {
		global $wpdb;

		$limit = min( absint( $limit ), self::MAX_LIMIT );
		if ( 0 === $limit ) {
			$limit = self::DEFAULT_LIMIT;
		}

		$table = self::get_table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, timestamp, endpoint, method, status_code, ip_address, user_agent
				FROM {$table}
				ORDER BY id DESC
				LIMIT %d",
				$limit
			),
			ARRAY_A
		);

		return is_array( $rows ) ? $rows : array();
	}

	// -----------------------------------------------------------------------
	// Prune — WP-Cron Callback
	// -----------------------------------------------------------------------

	/**
	 * Deletes log entries older than RETENTION_DAYS days.
	 *
	 * @return int Number of rows deleted, or 0 on failure.
	 */
	public function prune_logs(): int {
		global $wpdb;

		$table  = self::get_table_name();
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE timestamp < %s",
				$cutoff
			)
		);

		return is_int( $deleted ) ? $deleted : 0;
	}

	// -----------------------------------------------------------------------
	// Private — Request Context Extraction
	// -----------------------------------------------------------------------

	/**
	 * Extracts, validates, and GDPR-anonymizes the client IP address.
	 *
	 * @return string GDPR-anonymized IP address, or 'UNKNOWN' on failure.
	 */
	private function get_client_ip(): string {
		// Always use REMOTE_ADDR first — proxy headers are trivially spoofable.
		$remote_addr = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );

		if ( ! filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
			return 'UNKNOWN';
		}

		// Only trust proxy headers when REMOTE_ADDR is from a configured trusted proxy.
		/** @var string[] $trusted_proxies */
		$trusted_proxies = (array) get_option( 'stryke_wp_connector_trusted_proxies', array() );

		if ( ! empty( $trusted_proxies ) && in_array( $remote_addr, $trusted_proxies, true ) ) {
			$proxy_headers = array(
				'HTTP_CF_CONNECTING_IP',
				'HTTP_X_FORWARDED_FOR',
				'HTTP_X_REAL_IP',
			);

			foreach ( $proxy_headers as $header ) {
				if ( empty( $_SERVER[ $header ] ) ) {
					continue;
				}
				$raw       = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
				$candidate = trim( explode( ',', $raw )[0] );

				if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
					return $this->anonymize_ip( $candidate );
				}
			}
		}

		return $this->anonymize_ip( $remote_addr );
	}

	/**
	 * Anonymizes an IP address for GDPR compliance.
	 * IPv4: last octet zeroed. IPv6: last 80 bits zeroed.
	 *
	 * @param string $ip Raw IP address string.
	 * @return string Anonymized IP, or 'INVALID'.
	 */
	private function anonymize_ip( string $ip ): string {

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			$binary = inet_pton( $ip );
			if ( false === $binary ) {
				return 'INVALID';
			}
			$masked = $binary & inet_pton( '255.255.255.0' );
			return inet_ntop( $masked ) ?: 'INVALID';
		}

		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			$binary = inet_pton( $ip );
			if ( false === $binary || strlen( $binary ) !== 16 ) {
				return 'INVALID';
			}
			$masked = substr( $binary, 0, 6 ) . str_repeat( "\x00", 10 );
			return inet_ntop( $masked ) ?: 'INVALID';
		}

		return 'INVALID';
	}

	/**
	 * Returns a sanitized, length-capped User-Agent string.
	 *
	 * @return string Sanitized User-Agent, or empty string.
	 */
	private function get_user_agent(): string {
		if ( ! empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return substr(
				sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ),
				0,
				500
			);
		}
		return '';
	}
}
