<?php
/**
 * REST endpoint controller for clients.
 *
 * @since 1.0.0
 */

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

class ZINV_REST_Clients {

	private string $namespace;
	private string $rest_base;
	private ZINV_Client $client;

	public function __construct( string $namespace ) {
		$this->namespace = $namespace;
		$this->rest_base = 'clients';
		$this->client    = new ZINV_Client();
	}

	public function register(): void {
		add_filter( 'rest_pre_serve_request', [ $this, 'maybe_serve_export' ], 10, 3 );

		register_rest_route( $this->namespace, '/' . $this->rest_base, [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ $this, 'get_items' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => $this->get_collection_params(),
			],
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'create_item' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => $this->get_create_params(),
			],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ $this, 'get_item' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) > 0,
						'sanitize_callback' => 'absint',
					],
				],
			],
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ $this, 'update_item' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => $this->get_update_params(),
			],
			[
				'methods'             => WP_REST_Server::DELETABLE,
				'callback'            => [ $this, 'delete_item' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) > 0,
						'sanitize_callback' => 'absint',
					],
				],
			],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/wp-users', [
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => [ $this, 'get_wp_users' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => [
				'search' => [
					'type'              => 'string',
					'required'          => false,
					'sanitize_callback' => 'sanitize_text_field',
				],
			],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/export', [
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => [ $this, 'export_csv' ],
			'permission_callback' => [ $this, 'check_permission' ],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/import', [
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'import_csv' ],
			'permission_callback' => [ $this, 'check_permission' ],
		] );
	}

	public function check_permission(): bool {
		return current_user_can( 'zinv_manage_clients' );
	}

	public function get_wp_users( WP_REST_Request $request ): WP_REST_Response {
		$search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
		$args   = [
			'number'  => 200,
			'fields'  => [ 'ID', 'display_name', 'user_email' ],
			'orderby' => 'display_name',
			'order'   => 'ASC',
		];

		if ( ! empty( $search ) ) {
			$args['search']         = '*' . $search . '*';
			$args['search_columns'] = [ 'user_login', 'user_nicename', 'user_email', 'display_name' ];
		}

		$users = get_users( $args );
		$data  = [];

		foreach ( $users as $u ) {
			$data[] = [
				'id'           => (int) $u->ID,
				'display_name' => (string) $u->display_name,
				'email'        => (string) $u->user_email,
			];
		}

		return new WP_REST_Response( $data, 200 );
	}

	public function get_items( WP_REST_Request $request ): WP_REST_Response {
		$args = [
			'page'         => $request->get_param( 'page' ) ?? 1,
			'per_page'     => min( $request->get_param( 'per_page' ) ?? 20, 100 ),
			'search'       => sanitize_text_field( $request->get_param( 'search' ) ?? '' ),
			'status'       => sanitize_text_field( $request->get_param( 'status' ) ?? '' ),
			'account_type' => sanitize_text_field( $request->get_param( 'account_type' ) ?? '' ),
			'orderby'      => sanitize_text_field( $request->get_param( 'orderby' ) ?? 'created_at' ),
			'order'        => sanitize_text_field( $request->get_param( 'order' ) ?? 'DESC' ),
		];

		$result = $this->client->list( $args );

		$data = [
			'items'       => $result['items'],
			'total'       => (int) $result['total'],
			'total_pages' => (int) $result['total_pages'],
			'page'        => (int) $result['page'],
			'per_page'    => (int) $result['per_page'],
		];

		$response = new WP_REST_Response( $data, 200 );
		$response->header( 'X-WP-Total', (int) $result['total'] );
		$response->header( 'X-WP-TotalPages', (int) $result['total_pages'] );

		return $response;
	}

	public function get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$id     = absint( $request->get_param( 'id' ) );
		$client = $this->client->get( $id );

		if ( ! $client ) {
			return new WP_Error(
				'zinv_client_not_found',
				__( 'Client not found.', 'zoventic-invoices' ),
				[ 'status' => 404 ]
			);
		}

		return new WP_REST_Response( $client, 200 );
	}

	public function create_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$data = [
			'name'          => sanitize_text_field( $request->get_param( 'name' ) ),
			'email'         => sanitize_email( $request->get_param( 'email' ) ),
			'phone'         => sanitize_text_field( $request->get_param( 'phone' ) ?? '' ),
			'company'       => sanitize_text_field( $request->get_param( 'company' ) ?? '' ),
			'website'       => esc_url_raw( $request->get_param( 'website' ) ?? '' ),
			'address_line1' => sanitize_text_field( $request->get_param( 'address_line1' ) ?? '' ),
			'address_line2' => sanitize_text_field( $request->get_param( 'address_line2' ) ?? '' ),
			'city'          => sanitize_text_field( $request->get_param( 'city' ) ?? '' ),
			'state'         => sanitize_text_field( $request->get_param( 'state' ) ?? '' ),
			'postal_code'   => sanitize_text_field( $request->get_param( 'postal_code' ) ?? '' ),
			'country'       => sanitize_text_field( $request->get_param( 'country' ) ?? '' ),
			'account_type'  => sanitize_text_field( $request->get_param( 'account_type' ) ?? 'standard' ),
			'tax_id'        => sanitize_text_field( $request->get_param( 'tax_id' ) ?? '' ),
			'notes'         => sanitize_textarea_field( $request->get_param( 'notes' ) ?? '' ),
			'currency'      => sanitize_text_field( $request->get_param( 'currency' ) ?? 'USD' ),
			'tax_exempt'    => (int) (bool) $request->get_param( 'tax_exempt' ),
		];

		$wp_user_id = null;
		if ( $request->get_param( 'wp_user_id' ) ) {
			$wp_user_id = absint( $request->get_param( 'wp_user_id' ) );
			if ( ! get_userdata( $wp_user_id ) ) {
				return new WP_Error( 'zinv_invalid_wp_user', __( 'The specified WordPress user does not exist.', 'zoventic-invoices' ), [ 'status' => 400 ] );
			}
			if ( $wp_user_id !== get_current_user_id() && ! current_user_can( 'edit_users' ) ) {
				return new WP_Error( 'zinv_forbidden_wp_user', __( 'You cannot link a client to another user account.', 'zoventic-invoices' ), [ 'status' => 403 ] );
			}
		}
		$data['wp_user_id'] = $wp_user_id;

		if ( empty( $data['name'] ) || empty( $data['email'] ) ) {
			return new WP_Error(
				'zinv_client_missing_fields',
				__( 'Name and email are required.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		if ( ! is_email( $data['email'] ) ) {
			return new WP_Error(
				'zinv_client_invalid_email',
				__( 'Invalid email address.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		if ( $this->client->get_by_email( $data['email'] ) ) {
			return new WP_Error(
				'zinv_client_duplicate_email',
				__( 'A client with this email address already exists.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		$id = $this->client->create( $data );

		if ( ! $id ) {
			return new WP_Error(
				'zinv_client_create_failed',
				__( 'Failed to create client.', 'zoventic-invoices' ),
				[ 'status' => 500 ]
			);
		}

		do_action( 'zinv_client_created', $id, $data );

		$client = $this->client->get( $id );

		$response = new WP_REST_Response( $client, 201 );
		$response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $id ) ) );

		return $response;
	}

	public function update_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$id      = absint( $request->get_param( 'id' ) );
		$version = absint( $request->get_param( 'version' ) );

		if ( ! $version ) {
			return new WP_Error(
				'zinv_client_version_required',
				__( 'Version is required for optimistic locking.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		$existing = $this->client->get( $id );
		if ( ! $existing ) {
			return new WP_Error(
				'zinv_client_not_found',
				__( 'Client not found.', 'zoventic-invoices' ),
				[ 'status' => 404 ]
			);
		}

		$sanitizers = [
			'name'          => 'sanitize_text_field',
			'email'         => 'sanitize_email',
			'phone'         => 'sanitize_text_field',
			'company'       => 'sanitize_text_field',
			'website'       => 'esc_url_raw',
			'address_line1' => 'sanitize_text_field',
			'address_line2' => 'sanitize_text_field',
			'city'          => 'sanitize_text_field',
			'state'         => 'sanitize_text_field',
			'postal_code'   => 'sanitize_text_field',
			'country'       => 'sanitize_text_field',
			'account_type'  => 'sanitize_text_field',
			'tax_id'        => 'sanitize_text_field',
			'notes'         => 'sanitize_textarea_field',
			'currency'      => 'sanitize_text_field',
			'status'        => 'sanitize_text_field',
			'tax_exempt'    => static fn( $v ) => (int) (bool) $v,
		];

		$data = [];
		foreach ( $sanitizers as $field => $fn ) {
			$value = $request->get_param( $field );
			if ( null !== $value ) {
				$data[ $field ] = $fn( $value );
			}
		}

		// wp_user_id IDOR guard — same check as create_item().
		if ( null !== $request->get_param( 'wp_user_id' ) ) {
			$wp_user_id = absint( $request->get_param( 'wp_user_id' ) );
			if ( $wp_user_id > 0 ) {
				if ( ! get_userdata( $wp_user_id ) ) {
					return new WP_Error( 'zinv_invalid_wp_user', __( 'The specified WordPress user does not exist.', 'zoventic-invoices' ), [ 'status' => 400 ] );
				}
				if ( $wp_user_id !== get_current_user_id() && ! current_user_can( 'edit_users' ) ) {
					return new WP_Error( 'zinv_forbidden_wp_user', __( 'You cannot link a client to another user account.', 'zoventic-invoices' ), [ 'status' => 403 ] );
				}
				$data['wp_user_id'] = $wp_user_id;
			} else {
				$data['wp_user_id'] = null;
			}
		}

		if ( empty( $data ) ) {
			return new WP_Error(
				'zinv_client_no_fields',
				__( 'No fields to update.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		if ( ! empty( $data['email'] ) && $this->client->get_by_email( $data['email'], $id ) ) {
			return new WP_Error(
				'zinv_client_duplicate_email',
				__( 'A client with this email address already exists.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		$updated = $this->client->update( $id, $data, $version );

		if ( ! $updated ) {
			return new WP_Error(
				'zinv_client_version_conflict',
				__( 'Update failed. The record may have been modified by another user.', 'zoventic-invoices' ),
				[ 'status' => 409 ]
			);
		}

		do_action( 'zinv_client_updated', $id, $data );

		return new WP_REST_Response( $this->client->get( $id ), 200 );
	}

	public function delete_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$id      = absint( $request->get_param( 'id' ) );
		$existing = $this->client->get( $id );

		if ( ! $existing ) {
			return new WP_Error(
				'zinv_client_not_found',
				__( 'Client not found.', 'zoventic-invoices' ),
				[ 'status' => 404 ]
			);
		}

		$archived = $this->client->archive( $id );

		if ( ! $archived ) {
			return new WP_Error(
				'zinv_client_archive_failed',
				__( 'Failed to archive client.', 'zoventic-invoices' ),
				[ 'status' => 500 ]
			);
		}

		do_action( 'zinv_client_archived', $id );

		return new WP_REST_Response( [
			'deleted'  => true,
			'previous' => $existing,
		], 200 );
	}

	private function get_collection_params(): array {
		return [
			'page' => [
				'type'              => 'integer',
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) >= 1,
			],
			'per_page' => [
				'type'              => 'integer',
				'default'           => 20,
				'sanitize_callback' => 'absint',
				'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) >= 1 && absint( $v ) <= 100,
			],
			'search' => [
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			],
			'status' => [
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => empty( $v ) || in_array( $v, [ 'active', 'inactive', 'archived' ], true ),
			],
			'account_type' => [
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => empty( $v ) || in_array( $v, [ 'standard', 'enterprise', 'at_risk' ], true ),
			],
			'orderby' => [
				'type'              => 'string',
				'default'           => 'created_at',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => in_array( $v, [ 'id', 'name', 'email', 'total_ltv', 'status', 'created_at' ], true ),
			],
			'order' => [
				'type'              => 'string',
				'default'           => 'DESC',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => in_array( strtoupper( $v ), [ 'ASC', 'DESC' ], true ),
			],
		];
	}

	private function get_create_params(): array {
		return [
			'name' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => ! empty( $v ),
			],
			'email' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_email',
				'validate_callback' => fn( $v ) => is_email( $v ),
			],
		];
	}

	private function get_update_params(): array {
		return [
			'id' => [
				'required'          => true,
				'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) > 0,
				'sanitize_callback' => 'absint',
			],
			'version' => [
				'required'          => true,
				'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) > 0,
				'sanitize_callback' => 'absint',
			],
		];
	}

	public function import_csv( WP_REST_Request $request ): WP_REST_Response {
		$files = $request->get_file_params();

		if ( empty( $files['file']['tmp_name'] ) ) {
			return new WP_REST_Response( [ 'message' => __( 'No file uploaded.', 'zoventic-invoices' ) ], 400 );
		}

		// Size cap: 5MB max.
		$size = isset( $files['file']['size'] ) ? (int) $files['file']['size'] : 0;
		if ( $size <= 0 || $size > 5 * 1024 * 1024 ) {
			return new WP_REST_Response( [ 'message' => __( 'File must be between 1 byte and 5MB.', 'zoventic-invoices' ) ], 413 );
		}

		// Extension check (CSV only).
		$name = isset( $files['file']['name'] ) ? sanitize_file_name( $files['file']['name'] ) : '';
		$ext  = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
		if ( 'csv' !== $ext ) {
			return new WP_REST_Response( [ 'message' => __( 'File must have a .csv extension.', 'zoventic-invoices' ) ], 422 );
		}

		$tmp_path = $files['file']['tmp_name'];

		// Rate-limit CSV imports: one import per user per 30 seconds to prevent
		// database flooding via rapid bulk-upload requests.
		$rate_key = 'zinv_csv_import_' . get_current_user_id();
		if ( get_transient( $rate_key ) ) {
			return new WP_REST_Response(
				[ 'message' => __( 'Please wait 30 seconds before importing again.', 'zoventic-invoices' ) ],
				429
			);
		}
		set_transient( $rate_key, true, 30 );

		// Primary MIME check via WP API — works reliably cross-platform and
		// validates actual file content, not just the browser-supplied type.
		$check = wp_check_filetype_and_ext( $tmp_path, $name );
		if ( empty( $check['ext'] ) || 'csv' !== $check['ext'] ) {
			// wp_check_filetype_and_ext may not recognise CSV on all servers;
			// fall back to mime_content_type() as a secondary check.
			$mime = function_exists( 'mime_content_type' ) ? mime_content_type( $tmp_path ) : '';
			if ( ! $mime || ! in_array( $mime, [ 'text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel' ], true ) ) {
				return new WP_REST_Response( [ 'message' => __( 'File must be a valid CSV (MIME check failed).', 'zoventic-invoices' ) ], 422 );
			}
		}

		if ( ! is_uploaded_file( $tmp_path ) ) {
			return new WP_REST_Response( [ 'message' => __( 'File was not uploaded via HTTP POST.', 'zoventic-invoices' ) ], 400 );
		}

		// Read first bytes to guard against PHP-script renamed to .csv.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$header_bytes = file_get_contents( $tmp_path, false, null, 0, 8 );
		if ( false !== $header_bytes && 0 === strpos( ltrim( $header_bytes ), '<?' ) ) {
			return new WP_REST_Response( [ 'message' => __( 'File type not allowed.', 'zoventic-invoices' ) ], 422 );
		}

		// Native fopen is used here for stream-based CSV parsing (fgetcsv) from the temporary upload path.
		$handle = fopen( $tmp_path, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( ! $handle ) {
			return new WP_REST_Response( [ 'message' => __( 'Could not read file.', 'zoventic-invoices' ) ], 500 );
		}

		$max_rows  = 10000;
		$max_errs  = 100;
		$headers   = null;
		$created   = 0;
		$skipped   = 0;
		$errors    = [];
		$row_index = 0;

		while ( ( $row = fgetcsv( $handle ) ) !== false ) {
			$row_index++;

			if ( $row_index > $max_rows ) {
				/* translators: %d: maximum number of allowed rows */
				$errors[] = sprintf( esc_html__( 'Row cap reached (%d). Remaining rows skipped.', 'zoventic-invoices' ), $max_rows );
				break;
			}

			if ( $headers === null ) {
				$headers = array_map( 'strtolower', array_map( 'trim', $row ) );
				continue;
			}

			if ( count( $row ) < count( $headers ) ) {
				$row = array_pad( $row, count( $headers ), '' );
			}

			$data = array_combine( $headers, $row );

			$name  = sanitize_text_field( $data['name'] ?? $data['full name'] ?? '' );
			$email = sanitize_email( $data['email'] ?? $data['email address'] ?? '' );

			if ( ! $name && ! $email ) {
				$skipped++;
				continue;
			}

			if ( $email && ! is_email( $email ) ) {
				if ( count( $errors ) < $max_errs ) {
					/* translators: 1: row index, 2: invalid email address */
					$errors[] = sprintf( esc_html__( 'Row %1$d: invalid email "%2$s"', 'zoventic-invoices' ), $row_index, esc_html( $email ) );
				}
				$skipped++;
				continue;
			}

			$client_data = [
				/* translators: %d: row index */
				'name'          => $name ?: ( $email ? explode( '@', $email )[0] : sprintf( esc_html__( 'Row %d', 'zoventic-invoices' ), $row_index ) ),
				'email'         => $email,
				'phone'         => sanitize_text_field( $data['phone'] ?? '' ),
				'company'       => sanitize_text_field( $data['company'] ?? '' ),
				'account_type'  => in_array( $data['account_type'] ?? '', [ 'enterprise', 'standard', 'at_risk' ], true ) ? $data['account_type'] : 'standard',
				'address_line1' => sanitize_text_field( $data['address'] ?? $data['address_line1'] ?? '' ),
				'city'          => sanitize_text_field( $data['city'] ?? '' ),
				'state'         => sanitize_text_field( $data['state'] ?? '' ),
				'postal_code'   => sanitize_text_field( $data['postal_code'] ?? $data['postcode'] ?? $data['zip'] ?? '' ),
				'country'       => sanitize_text_field( $data['country'] ?? '' ),
				'currency'      => sanitize_text_field( $data['currency'] ?? 'USD' ),
				'notes'         => sanitize_textarea_field( $data['notes'] ?? '' ),
			];

			$id = $this->client->create( $client_data );

			if ( $id ) {
				$created++;
			} else {
				if ( count( $errors ) < $max_errs ) {
					/* translators: %d: row index */
					$errors[] = sprintf( esc_html__( 'Row %d: failed to create client.', 'zoventic-invoices' ), $row_index );
				}
				$skipped++;
			}
		}

		fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose

		if ( $created > 0 ) {
			do_action( 'zinv_client_created', 0, [] );
		}

		return new WP_REST_Response( [
			'created' => $created,
			'skipped' => $skipped,
			'errors'  => $errors,
		], 201 );
	}

	public function export_csv( WP_REST_Request $request ): WP_REST_Response {
		$response = new WP_REST_Response( null, 200 );
		$response->header( 'X-ZINV-EXPORT-CLIENTS', '1' );
		return $response;
	}

	public function maybe_serve_export( $served, $result, $request ) {
		if ( $served || ! ( $result instanceof WP_HTTP_Response ) ) {
			return $served;
		}

		$headers = $result->get_headers();
		if ( empty( $headers['X-ZINV-EXPORT-CLIENTS'] ) ) {
			return $served;
		}

		$status_raw       = sanitize_text_field( $request->get_param( 'status' ) ?? '' );
		$account_type_raw = sanitize_text_field( $request->get_param( 'account_type' ) ?? '' );

		$status        = in_array( $status_raw, [ '', 'active', 'inactive', 'archived' ], true ) ? $status_raw : '';
		$account_type  = in_array( $account_type_raw, [ '', 'standard', 'enterprise', 'at_risk' ], true ) ? $account_type_raw : '';

		$args = [
			'page'          => 1,
			'per_page'      => 5000,
			'_max_per_page' => 5000,
			'search'        => sanitize_text_field( $request->get_param( 'search' ) ?? '' ),
			'status'        => $status,
			'account_type'  => $account_type,
			'orderby'       => 'name',
			'order'         => 'ASC',
		];

		$result  = $this->client->list( $args );
		$clients = $result['items'] ?? [];

		$csv_headers = [ 'ID', 'Name', 'Email', 'Phone', 'Company', 'Account Type', 'Status', 'Address Line 1', 'City', 'State', 'Postal Code', 'Country', 'Currency', 'Notes', 'Created At' ];

		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="clients-' . gmdate( 'Y-m-d' ) . '.csv"' );
		header( 'Cache-Control: no-store' );

		$out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		fputcsv( $out, $csv_headers );

		foreach ( $clients as $client ) {
			$row = (array) $client;
			fputcsv( $out, [
				$row['id']            ?? '',
				self::sanitize_csv_value( (string) ( $row['name']          ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['email']         ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['phone']         ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['company']       ?? '' ) ),
				$row['account_type']  ?? '',
				$row['status']        ?? '',
				self::sanitize_csv_value( (string) ( $row['address_line1'] ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['city']          ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['state']         ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['postal_code']   ?? '' ) ),
				self::sanitize_csv_value( (string) ( $row['country']       ?? '' ) ),
				$row['currency']      ?? '',
				self::sanitize_csv_value( (string) ( $row['notes']         ?? '' ) ),
				$row['created_at']    ?? '',
			] );
		}

		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		return true;
	}

	public static function sanitize_csv_value( string $v ): string {
		// Strip formula injection prefixes (=, +, -, @, tab, CR) to prevent spreadsheet attacks.
		if ( isset( $v[0] ) && in_array( $v[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
			$v = "'" . $v;
		}
		return $v;
	}
}
