<?php
/**
 * Writer: CSV writer.
 *
 * CSV file writer class.
 *
 * @since 4.6.1
 *
 * @package   wsal
 * @subpackage writers
 */

declare(strict_types=1);

namespace WSAL\Writers;

use WSAL\Helpers\WP_Helper;
use WSAL\Helpers\User_Helper;
use WSAL\Controllers\Connection;
use WSAL\Helpers\Settings_Helper;
use WSAL\Extensions\Views\Reports;
use WSAL\Controllers\Alert_Manager;
use WSAL\Entities\Occurrences_Entity;
use WSAL\ListAdminEvents\List_Events;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( '\WSAL\Writers\CSV_Writer' ) ) {
	/**
	 * Provides logging functionality for the comments.
	 *
	 * @since 4.6.1
	 */
	class CSV_Writer {
		/**
		 * Holds the array with the events to store as CSV
		 *
		 * @var array
		 *
		 * @since 4.6.1
		 */
		private static $events = array();

		/**
		 * Holds the full file name for the CSV file
		 *
		 * @var string
		 *
		 * @since 4.6.1
		 */
		private static $file_name = '';

		/**
		 * Holds the columns for the CSV file
		 *
		 * @var array
		 *
		 * @since 5.0.0
		 */
		private static $columns_header = array();

		/**
		 * Inits the class hooks if necessary.
		 *
		 * @return void
		 *
		 * @since 5.0.0
		 */
		public static function init() {
			if ( \class_exists( '\WSAL\Extensions\Views\Reports' ) ) {
				\add_action( 'wp_ajax_wsal_report_download', array( Reports::class, 'process_report_download' ) );
			}
		}

		/**
		 * Create a short-lived token for a CSV export query.
		 *
		 * @param array $query - Query conditions generated by the audit log list table.
		 * @param array $order - Query order generated by the audit log list table.
		 *
		 * @return string - Export token.
		 *
		 * @since 5.6.4
		 */
		public static function create_export_token( array $query, array $order ): string {
			$token = \wp_generate_password( 32, false, false );

			\set_transient(
				self::get_export_transient_key( $token ),
				array(
					'query' => $query,
					'order' => $order,
				),
				HOUR_IN_SECONDS
			);

			return $token;
		}

		/**
		 * Build the transient key for a CSV export token.
		 *
		 * @param string $token - Export token.
		 *
		 * @return string - Transient key.
		 *
		 * @since 5.6.4
		 */
		private static function get_export_transient_key( string $token ): string {
			return 'wsal_csv_export_' . (int) \get_current_user_id() . '_' . md5( $token );
		}

		/**
		 * Get export data for a token.
		 *
		 * @param string $token - Export token.
		 *
		 * @return array - Export query and order data.
		 *
		 * @since 5.6.4
		 */
		private static function get_export_data( string $token ): array {
			if ( '' === $token ) {
				return array();
			}

			$export_data = \get_transient( self::get_export_transient_key( $token ) );

			if ( ! is_array( $export_data ) ) {
				return array();
			}

			if ( ! isset( $export_data['query'], $export_data['order'] ) || ! is_array( $export_data['query'] ) || ! is_array( $export_data['order'] ) ) {
				return array();
			}

			return $export_data;
		}

		/**
		 * Validate CSV export order fields and directions.
		 *
		 * @param array $order - Query order generated by the audit log list table.
		 *
		 * @return array|\WP_Error - Sanitized order array or validation error.
		 *
		 * @since 5.6.4
		 */
		private static function sanitize_export_order( array $order ) {
			if ( empty( $order ) ) {
				return new \WP_Error( 'invalid_order', \esc_html__( 'Invalid export order.', 'wp-security-audit-log' ) );
			}

			$allowed_fields = array_keys( Occurrences_Entity::get_fields() );
			$clean_order    = array();

			foreach ( $order as $field => $direction ) {
				$field     = (string) $field;
				$direction = strtoupper( (string) $direction );

				if ( ! in_array( $field, $allowed_fields, true ) ) {
					return new \WP_Error( 'invalid_order_field', \esc_html__( 'Invalid export order field.', 'wp-security-audit-log' ) );
				}

				if ( ! in_array( $direction, array( 'ASC', 'DESC' ), true ) ) {
					return new \WP_Error( 'invalid_order_direction', \esc_html__( 'Invalid export order direction.', 'wp-security-audit-log' ) );
				}

				$clean_order[ $field ] = $direction;
			}

			return $clean_order;
		}

		/**
		 * Sanitize a CSV row before writing it to the file.
		 *
		 * Casts all values to strings, strips &nbsp; HTML entities, and sanitizes
		 * each field to prevent formula injection in spreadsheet software.
		 *
		 * @param array $row - The row values to sanitize.
		 *
		 * @return array - The sanitized row.
		 *
		 * @since 5.6.2
		 */
		private static function sanitize_csv_row( array $row ): array {
			// Ensure all values are strings to prevent PHP warnings.
			$ensure_string = array_map( 'strval', $row );

			// Strip &nbsp; HTML entities before writing.
			$remove_html_encoded_space = str_replace( '&nbsp;', ' ', $ensure_string );

			// Sanitize values to prevent formula injection in spreadsheet software.
			$clean_row = array_map( array( self::class, 'sanitize_csv_field' ), $remove_html_encoded_space );

			return $clean_row;
		}

		/**
		 * Writes the CSV file to the specified location.
		 *
		 * @param int   $step - Current step.
		 * @param array $data - If local variable is not set, data array could be passed to that method.
		 *
		 * @return void
		 *
		 * @since 4.6.1
		 */
		public static function write_csv( int $step, array &$data = array() ) {

			if ( 1 === $step ) {
				$output = fopen( self::get_file(), 'w' );
			} else {
				$output = fopen( self::get_file(), 'a+' );
			}
			if ( 1 === $step ) {
				fputcsv( $output, self::prepare_header(), ',', '"', '\\' );
			}
			$enclosure            = '"';
			$csv_export_separator = ',';

			// Load all alerts once for filtering excluded links.
			$all_alerts = array();

			if ( \class_exists( '\WSAL\Extensions\Views\Reports' ) ) {
				$all_alerts = Alert_Manager::get_alerts();
			}

			if ( ! empty( $data ) ) {

				foreach ( $data as $row ) {
					$current_row = array();

					foreach ( array_keys( self::prepare_header() ) as $column_name ) {
						if ( 'message' == $column_name ) {
							$html = htmlspecialchars_decode(
								\trim(
									\strip_tags(
										str_replace(
											array( '<br />', '<br>' ),
											"\n",
											wp_specialchars_decode( List_events::format_column_value( $row, $column_name ) )
										),
										array( '<a>' )
									)
								)
							);

							$split_text = explode( '<a', $html );

							$final_text = '';

							foreach ( $split_text as $line ) {
								if ( str_starts_with( trim( $line ), 'href' ) ) {
									$a_info = self::link_extractor( '<a' . $line );

									if ( '#' !== $a_info[0][0] ) {
										$final_text .= $a_info[0][1] . ': ' . $a_info[0][0];
									}
								} else {
									$final_text .= $line;
								}
							}
							$current_row[] = $final_text;
						} else {
							$current_row[] = htmlspecialchars_decode(
								\trim(
									\strip_tags(
										str_replace(
											array( '<br />', '<br>' ),
											"\n",
											wp_specialchars_decode( List_events::format_column_value( $row, $column_name ) )
										)
									)
								)
							);
						}
					}

					$current_row = self::sanitize_csv_row( $current_row );

					fputcsv( $output, $current_row, $csv_export_separator, $enclosure, '\\' );
				}
			} else {

				foreach ( self::$events as $row ) {
					$current_row = array();

					foreach ( array_keys( self::prepare_header() ) as $column_name ) {
						if ( 'mesg' == $column_name ) {
							// Get raw message and filter out excluded links.
							$raw_message = List_events::format_column_value( $row, $column_name );

							if ( isset( $row['alert_id'] ) && ! empty( $all_alerts ) && \class_exists( '\WSAL\Extensions\Views\Reports' ) ) {
								$raw_message = Reports::maybe_exclude_alert_message_from_report( $raw_message, (int) $row['alert_id'], $all_alerts );
							}

							$html = htmlspecialchars_decode(
								\trim(
									\strip_tags(
										str_replace( array( '<br />', '<br>', '<br/>', '</br>' ), "\n", $raw_message ),
										array( 'a' )
									)
								)
							);

							$split_text = explode( '<a', $html );

							$final_text = '';

							foreach ( $split_text as $line ) {
								if ( str_starts_with( trim( $line ), 'href' ) ) {
									$a_info = self::link_extractor( '<a' . $line );

									if ( '#' !== $a_info[0][0] ) {
										$final_text .= $a_info[0][1] . ': ' . $a_info[0][0];
									} else {
										$final_text .= "\n";
									}
								} else {
									$final_text .= $line;
								}
							}
							$current_row[] = $final_text;
						} else {
							$current_row[] = htmlspecialchars_decode(
								\trim(
									\strip_tags(
										str_replace( array( '<br />', '<br>', '<br/>', '</br>' ), "\n", List_events::format_column_value( $row, $column_name ) )
									)
								)
							);
						}
					}

					$current_row = self::sanitize_csv_row( $current_row );

					fputcsv( $output, $current_row, $csv_export_separator, $enclosure, '\\' );
				}
			}

			fclose( $output );
		}

		/**
		 * Sanitize a CSV field value to prevent formula injection.
		 *
		 * Prefixes values starting with formula-triggering characters with a single
		 * quote to prevent spreadsheet software from interpreting them as formulas.
		 *
		 * @param string $value - The cell value to sanitize.
		 *
		 * @return string - The sanitized value.
		 *
		 * @since 5.6.2
		 */
		private static function sanitize_csv_field( string $value ): string {
			if ( '' === $value ) {
				return $value;
			}

			$first_char = $value[0];

			if ( in_array( $first_char, array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
				return "'" . $value;
			}

			return $value;
		}

		/**
		 * Extract link info from a given text
		 *
		 * @param string $html - The text to parse.
		 *
		 * @return array
		 *
		 * @since 5.0.0
		 */
		private static function link_extractor( $html ) {
			$link_array = array();
			if ( preg_match_all( '/<a\s+.*?href=[\"\']?([^\"\' >]*)[\"\']?[^>]*>(.*?)<\/a>/i', $html, $matches, PREG_SET_ORDER ) ) {
				foreach ( $matches as $match ) {
					array_push( $link_array, array( $match[1], $match[2] ) );
				}
			}

			return $link_array;
		}

		/**
		 * Accepts the AJAX requests and checks the params then calls the main function of the class responsible for writing the file
		 *
		 * @return void
		 *
		 * @since 4.6.1
		 * @since 5.6.4 - Replaced the client-supplied serialized query with a server-side export token, added a capability check and order validation.
		 */
		public static function write_csv_ajax() {
			if ( ! Settings_Helper::current_user_can( 'view' ) ) {
				\wp_send_json_error( \esc_html__( 'You do not have permission to export audit log data.', 'wp-security-audit-log' ), 403 );
			}

			$nonce = \sanitize_text_field( \wp_unslash( $_POST['nonce'] ?? '' ) );

			if ( ! \wp_verify_nonce( $nonce, 'wsal-export-csv-nonce' ) ) {
				\wp_send_json_error( \esc_html__( 'nonce is not provided or incorrect', 'wp-security-audit-log' ), 403 );
			}

			$export_token = \sanitize_text_field( \wp_unslash( $_POST['export_token'] ?? '' ) );

			$export_data = self::get_export_data( $export_token );

			if ( empty( $export_data ) ) {
				\wp_send_json_error( \esc_html__( 'Export data is not provided or incorrect', 'wp-security-audit-log' ), 400 );
			}

			$order = self::sanitize_export_order( $export_data['order'] );

			if ( \is_wp_error( $order ) ) {
				\wp_send_json_error( $order->get_error_message(), 400 );
			}

			$step    = \absint( \wp_unslash( $_POST['step'] ?? 0 ) );
			$records = \absint( \wp_unslash( $_POST['records'] ?? 0 ) );

			if ( 1 > $step ) {
				\wp_send_json_error( \esc_html__( 'step is not provided or incorrect', 'wp-security-audit-log' ), 400 );
			}

			if ( 1 > $records ) {
				\wp_send_json_error( \esc_html__( 'records is not provided or incorrect', 'wp-security-audit-log' ), 400 );
			}

			self::$events = self::query_table( $export_data['query'], $order, $step, $records );

			// Count self events, used in client side code to show more accurate progress.
			$self_events_count = count( self::$events );

			$exports_path = Settings_Helper::get_working_dir_path_static( 'exports' );
			if ( $exports_path instanceof \WP_Error ) {
				\wp_send_json_error( $exports_path->get_error_message() );
				die();
			}

			self::set_file( $exports_path . 'exports-user' . User_Helper::get_user()->ID . '.csv' );

			$url = \add_query_arg(
				array(
					'action' => 'wsal_report_download',
					'f'      => base64_encode( \basename( self::$file_name ) ),
					'ctype'  => 'csv',
					'dir'    => 'exports',
					'nonce'  => \wp_create_nonce( 'wpsal_reporting_nonce_action' ),
				),
				\admin_url( 'admin-ajax.php' )
			);

			if ( ! empty( self::$events ) ) {
				self::write_csv( $step );

				\wp_send_json_success(
					array(
						'step'         => ++$step,
						'file_name'    => \basename( self::$file_name ),
						'url'          => $url,
						'record_count' => $self_events_count,
					)
				);
			} else {

				\wp_send_json_success(
					array(
						'step'      => 0,
						'file_name' => \basename( self::$file_name ),
						'url'       => $url,
					)
				);
			}
		}

		/**
		 * Sets the writer file name.
		 *
		 * @param string $file - The name of the writer file to be used.
		 *
		 * @return void
		 *
		 * @since 5.0.0
		 */
		public static function set_file( string $file ) {
			self::$file_name = $file;
		}

		/**
		 * Returns the writer file name.
		 *
		 * @return string
		 *
		 * @since 5.0.0
		 */
		public static function get_file(): string {
			return (string) self::$file_name;
		}

		/**
		 * Sets the header_columns property to be used in csv generation
		 *
		 * @param array $columns - The column names.
		 *
		 * @return void
		 *
		 * @since 5.0.0
		 */
		public static function set_header_columns( array $columns ) {
			self::$columns_header = $columns;
		}

		/**
		 * Sends a query to the database and returns the results.
		 *
		 * @param array $query - The query array.
		 * @param array $order - The order used for the query.
		 * @param int   $step - The current step of the extracting data (limit).
		 * @param int   $per_page - How many records to be extracted in one hop.
		 *
		 * @return array
		 *
		 * @since 4.6.1
		 */
		private static function query_table( array $query, array $order, int $step = 0, int $per_page = 0 ) {
			$wsal_db = null;
			if ( Settings_Helper::is_archiving_enabled() ) {
				// Switch to Archive DB.
				$selected_db = WP_Helper::get_transient( 'wsal_wp_selected_db' );
				if ( $selected_db && 'archive' === $selected_db ) {
					if ( \class_exists( '\WSAL_Extension_Manager' ) ) {
						if ( \class_exists( '\WSAL_Ext_Plugin' ) ) {
							$connection_name = Settings_Helper::get_option_value( 'archive-connection' );

							$wsal_db = Connection::get_connection( $connection_name );
						}
					}
				}
			}

			/**
			 * Calculate offset for the query, we need to subtract 1 if step > 1 to get the correct offset.
			 */
			$offset = max( 0, ( $step > 1 ? ( $step - 1 ) * $per_page : 0 ) );

			// Specify the correct range of records, from $offset to the next $per_page value.
			$limit = array( $offset, $per_page );

			$events = Occurrences_Entity::build_query(
				array(),
				$query,
				$order,
				$limit,
				array(),
				$wsal_db
			);

			$events = Occurrences_Entity::get_multi_meta_array( $events, $wsal_db );

			return $events;
		}

		/**
		 * Prepares the header columns for the CSV file
		 *
		 * @return array
		 *
		 * @since 4.6.1
		 */
		private static function prepare_header() {
			if ( empty( self::$columns_header ) ) {
				$all_columns = List_Events::manage_columns( array() );
				unset( $all_columns['cb'] );
				unset( $all_columns['data'] );
				$hidden_columns = List_Events::get_hidden_columns();

				if ( \is_array( $hidden_columns ) && ! empty( $hidden_columns ) ) {
					self::$columns_header = array_diff_key( $all_columns, array_flip( $hidden_columns ) );
				} else {
					self::$columns_header = $all_columns;
				}
			}

			return self::$columns_header;
		}
	}
}
