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

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

class ZINV_REST_Invoices {

	private string $namespace;
	private string $rest_base;
	private ZINV_Invoice $invoice;

	public function __construct( string $namespace ) {
		$this->namespace = $namespace;
		$this->rest_base = 'invoices';
		$this->invoice   = new ZINV_Invoice();
	}

	public function register(): void {
		// PDF/binary routes can't return through WP_REST_Server's JSON encoder —
		// it sends `Content-Type: application/json` before dispatch, so a raw
		// readfile() in the callback ships PDF bytes mislabelled as JSON and the
		// browser renders them as text. Take over the response via the official
		// rest_pre_serve_request filter so the JSON path is skipped entirely.
		add_filter( 'rest_pre_serve_request', [ $this, 'maybe_serve_pdf' ], 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'                => $this->get_id_param(),
			],
			[
				'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, 'void_item' ],
				'permission_callback' => [ $this, 'check_permission' ],
				'args'                => $this->get_id_param(),
			],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/peek-number', [
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => [ $this, 'peek_number' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => [
				'date' => [
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				],
			],
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)/send', [
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'send_item' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => $this->get_id_param(),
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)/pdf', [
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => [ $this, 'get_pdf' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => $this->get_id_param(),
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)/duplicate', [
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'duplicate_item' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => $this->get_id_param(),
		] );

		register_rest_route( $this->namespace, '/' . $this->rest_base . '/bulk', [
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => [ $this, 'bulk_action' ],
			'permission_callback' => [ $this, 'check_permission' ],
			'args'                => [
				'action' => [
					'required'          => true,
					'type'              => 'string',
					'validate_callback' => fn( $v ) => in_array( $v, [ 'send', 'void' ], true ),
				],
				'ids' => [
					'required'          => true,
					'type'              => 'array',
					'items'             => [ 'type' => 'integer', 'minimum' => 1 ],
					'validate_callback' => fn( $v ) => is_array( $v ) && count( $v ) > 0 && count( $v ) <= 100,
				],
			],
		] );

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

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

	public function peek_number( WP_REST_Request $request ): WP_REST_Response {
		$date   = sanitize_text_field( $request->get_param( 'date' ) ?? '' );
		$number = $this->invoice->peek_number( $date );
		return new WP_REST_Response( [ 'number' => $number ], 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'    => ( function( $v ) { $s = sanitize_text_field( $v ?? '' ); return $s === 'all' ? '' : $s; } )( $request->get_param( 'status' ) ),
			'client_id' => $request->get_param( 'client_id' ) ? absint( $request->get_param( 'client_id' ) ) : 0,
			'date_from' => sanitize_text_field( $request->get_param( 'date_from' ) ?? '' ),
			'date_to'   => sanitize_text_field( $request->get_param( 'date_to' ) ?? '' ),
			'orderby'   => sanitize_text_field( $request->get_param( 'orderby' ) ?? 'created_at' ),
			'order'     => sanitize_text_field( $request->get_param( 'order' ) ?? 'DESC' ),
		];

		$result = $this->invoice->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' ) );
		$invoice = $this->invoice->get( $id );

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

		return new WP_REST_Response( $invoice, 200 );
	}

	public function create_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$client_id = absint( $request->get_param( 'client_id' ) );

		if ( ! $client_id ) {
			return new WP_Error(
				'zinv_invoice_client_required',
				__( 'Client is required.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		$line_items = $request->get_param( 'line_items' ) ?? [];
		if ( ! is_array( $line_items ) ) {
			$line_items = [];
		}

		foreach ( $line_items as &$item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$item['description'] = sanitize_text_field( $item['description'] ?? '' );
		}
		unset( $item );

		$line_item_error = $this->validate_line_items( $line_items );
		if ( is_wp_error( $line_item_error ) ) {
			return $line_item_error;
		}

		$discount_type_raw = sanitize_text_field( $request->get_param( 'discount_type' ) ?? '' );
		if ( 'percent' === $discount_type_raw ) {
			$discount_type_raw = 'percentage';
		}

		$discount_value_raw = $request->get_param( 'discount_value' );
		$discount_value     = is_numeric( $discount_value_raw ) ? (string) $discount_value_raw : '0';
		if ( bccomp( $discount_value, '0', 8 ) < 0 ) {
			return new WP_Error( 'zinv_invoice_invalid_discount_value', __( 'Discount value cannot be negative.', 'zoventic-invoices' ), [ 'status' => 400 ] );
		}

		if ( '' !== $discount_type_raw && ! in_array( $discount_type_raw, [ 'percentage', 'fixed' ], true ) ) {
			return new WP_Error( 'zinv_invoice_invalid_discount_type', __( 'Invalid discount type.', 'zoventic-invoices' ), [ 'status' => 400 ] );
		}

		// Validate template against the allowlist (Pro-aware). Reject unknown
		// or Pro-only slugs on a free site so the DB never holds a value the
		// renderer would silently fall back from.
		$template_error = $this->validate_template( $request->get_param( 'template' ) );
		if ( is_wp_error( $template_error ) ) {
			return $template_error;
		}

		$data = [
			'client_id'       => $client_id,
			'issue_date'      => sanitize_text_field( $request->get_param( 'issue_date' ) ?? '' ),
			'due_date'        => sanitize_text_field( $request->get_param( 'due_date' ) ?? '' ),
			'currency'        => sanitize_text_field( $request->get_param( 'currency' ) ?? 'USD' ),
			'discount_type'   => $discount_type_raw,
			'discount_value'  => $discount_value,
			'document_title'  => sanitize_text_field( $request->get_param( 'document_title' ) ?? '' ),
			'notes'           => sanitize_textarea_field( $request->get_param( 'notes' ) ?? '' ),
			'terms'           => sanitize_textarea_field( $request->get_param( 'terms' ) ?? '' ),
			'footer_text'     => sanitize_text_field( $request->get_param( 'footer_text' ) ?? '' ),
		];

		// Recurring fields are Pro-only. Sanitize lives in ZINV_Invoice::create
		// (interval allowlist + count clamp); REST just forwards.
		if ( null !== $request->get_param( 'is_recurring' ) ) {
			$data['is_recurring'] = (bool) $request->get_param( 'is_recurring' );
		}
		if ( null !== $request->get_param( 'recurring_interval' ) ) {
			$data['recurring_interval'] = (string) $request->get_param( 'recurring_interval' );
		}
		if ( null !== $request->get_param( 'recurring_count' ) ) {
			$data['recurring_count'] = (int) $request->get_param( 'recurring_count' );
		}

		// Only forward an explicit template; absent → CRUD applies the saved default.
		$template_param = $request->get_param( 'template' );
		if ( null !== $template_param && '' !== $template_param ) {
			$data['template'] = sanitize_text_field( $template_param );
		}

		$id = $this->invoice->create( $data, $line_items );

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

		$invoice  = $this->invoice->get( $id );
		$response = new WP_REST_Response( $invoice, 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_invoice_version_required',
				__( 'Version is required for optimistic locking.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

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

		$immutable_statuses = [ 'paid', 'void', 'in_recovery' ];
		if ( in_array( $existing['status'], $immutable_statuses, true ) ) {
			return new WP_Error(
				'zinv_invoice_immutable',
				sprintf(
					/* translators: %s: invoice status */
					__( 'Cannot edit an invoice with status "%s".', 'zoventic-invoices' ),
					$existing['status']
				),
				[ 'status' => 409 ]
			);
		}

		$data = [];
		$sanitizers = [
			'client_id'          => 'absint',
			'issue_date'         => 'sanitize_text_field',
			'due_date'           => 'sanitize_text_field',
			'discount_type'      => 'sanitize_text_field',
			'discount_value'     => static fn( $v ) => is_numeric( $v ) ? (string) $v : '0',
			'template'           => 'sanitize_text_field',
			'document_title'     => 'sanitize_text_field',
			'notes'              => 'sanitize_textarea_field',
			'terms'              => 'sanitize_textarea_field',
			'footer_text'        => 'sanitize_text_field',
			'currency'           => 'sanitize_text_field',
			// Recurring update fields. ZINV_Invoice::update validates the
			// interval allowlist + clamps count; we just pass-through here.
			'is_recurring'       => static fn( $v ) => (bool) $v,
			'recurring_interval' => 'sanitize_text_field',
			'recurring_count'    => static fn( $v ) => max( 0, (int) $v ),
		];

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

		// Validate + normalize discount_type allowlist (accept legacy 'percent' as alias).
		if ( isset( $data['discount_type'] ) ) {
			if ( 'percent' === $data['discount_type'] ) {
				$data['discount_type'] = 'percentage';
			}
			if ( ! in_array( $data['discount_type'], [ '', 'percentage', 'fixed' ], true ) ) {
				return new WP_Error( 'zinv_invoice_invalid_discount_type', __( 'Invalid discount type.', 'zoventic-invoices' ), [ 'status' => 400 ] );
			}
		}

		// Reject negative discount_value.
		if ( isset( $data['discount_value'] ) && bccomp( (string) $data['discount_value'], '0', 8 ) < 0 ) {
			return new WP_Error( 'zinv_invoice_invalid_discount_value', __( 'Discount value cannot be negative.', 'zoventic-invoices' ), [ 'status' => 400 ] );
		}

		// Validate template against the allowlist when one is being changed.
		if ( isset( $data['template'] ) ) {
			$template_error = $this->validate_template( $data['template'] );
			if ( is_wp_error( $template_error ) ) {
				return $template_error;
			}
		}

		$line_items = $request->get_param( 'line_items' );
		if ( null !== $line_items && ! is_array( $line_items ) ) {
			$line_items = [];
		}

		if ( null !== $line_items ) {
			foreach ( $line_items as &$item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$item['description'] = sanitize_text_field( $item['description'] ?? '' );
			}
			unset( $item );

			$line_item_error = $this->validate_line_items( $line_items );
			if ( is_wp_error( $line_item_error ) ) {
				return $line_item_error;
			}
		}

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

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

		if ( class_exists( 'ZINV_PDF_Generator' ) ) {
			ZINV_PDF_Generator::delete_cached_pdf( $id );
		}

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

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

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

		$voided = $this->invoice->void( $id );

		if ( ! $voided ) {
			return new WP_Error(
				'zinv_invoice_void_failed',
				__( 'Cannot void this invoice.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		if ( class_exists( 'ZINV_PDF_Generator' ) ) {
			ZINV_PDF_Generator::delete_cached_pdf( $id );
		}

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

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

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

		$sent = $this->invoice->send( $id );

		if ( ! $sent ) {
			return new WP_Error(
				'zinv_invoice_send_failed',
				__( 'Cannot send this invoice.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

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

	public function bulk_action( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$action = sanitize_text_field( $request->get_param( 'action' ) );
		$ids    = array_map( 'absint', (array) $request->get_param( 'ids' ) );

		$succeeded = [];
		$failed    = [];

		foreach ( $ids as $id ) {
			$invoice = $this->invoice->get( $id );
			if ( ! $invoice ) {
				$failed[] = [ 'id' => $id, 'reason' => 'not_found' ];
				continue;
			}

			$ok = false;

			if ( 'send' === $action ) {
				$ok = $this->invoice->send( $id );
			} elseif ( 'void' === $action ) {
				$immutable = [ 'paid', 'void', 'in_recovery' ];
				if ( in_array( $invoice['status'], $immutable, true ) ) {
					$failed[] = [ 'id' => $id, 'reason' => 'immutable_status' ];
					continue;
				}
				$ok = $this->invoice->void( $id );
			}

			if ( $ok ) {
				$succeeded[] = $id;
			} else {
				$failed[] = [ 'id' => $id, 'reason' => 'action_failed' ];
			}
		}

		return new WP_REST_Response( [
			'action'    => $action,
			'succeeded' => $succeeded,
			'failed'    => $failed,
		], 200 );
	}

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

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

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

		// Clone line items safely
		$line_items = array_map( static function ( $li ) {
			$row = (array) $li;
			return [
				'description' => $row['description'] ?? '',
				'quantity'    => $row['quantity'] ?? 1,
				'unit_price'  => $row['unit_price'] ?? 0,
				'tax_rate'    => $row['tax_rate'] ?? 0,
				'sort_order'  => $row['sort_order'] ?? 0,
			];
		}, $existing['line_items'] ?? [] );

		// Prepare data for new invoice, stripping auto‑generated fields
		$data = $existing;
		unset( $data['id'], $data['status'], $data['created_at'], $data['updated_at'], $data['pdf_path'] );

		// Ensure required defaults
		$data['client_id']      = (int) ( $data['client_id'] ?? 0 );
		$data['issue_date']     = current_time( 'Y-m-d' );
		$data['due_date']       = $data['due_date'] ?? '';
		$data['currency']       = $data['currency'] ?? 'USD';
		$data['discount_type']  = $data['discount_type'] ?? '';
		$data['discount_value'] = $data['discount_value'] ?? 0;
		$data['template']       = $data['template'] ?? 'clean-sheet';
		$data['notes']          = $data['notes'] ?? '';
		$data['terms']          = $data['terms'] ?? '';
		$data['footer_text']    = $data['footer_text'] ?? '';

		if ( empty( $data['client_id'] ) ) {
			return new WP_Error(
				'zinv_duplicate_missing_client',
				__( 'Cannot duplicate invoice without a valid client.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		$new_id = $this->invoice->create( $data, $line_items );

		if ( ! $new_id ) {
			return new WP_Error(
				'zinv_invoice_duplicate_failed',
				__( 'Failed to duplicate invoice.', 'zoventic-invoices' ),
				[ 'status' => 500 ]
			);
		}

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

		return $response;
	}

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

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

		if ( ! class_exists( 'ZINV_PDF_Generator' ) ) {
			return new WP_Error(
				'zinv_pdf_not_available',
				__( 'PDF generation is not available.', 'zoventic-invoices' ),
				[ 'status' => 500 ]
			);
		}

		$disposition_raw = sanitize_text_field( (string) ( $request->get_param( 'disposition' ) ?? $request->get_param( 'download' ) ) );
		$disposition     = ( 'attachment' === $disposition_raw || '1' === $disposition_raw || 'true' === $disposition_raw ) ? 'attachment' : 'inline';

		// Don't stream here — WP has already sent the JSON Content-Type. Hand a
		// marker response to maybe_serve_pdf(), which runs on rest_pre_serve_request
		// (before JSON encoding) and streams the file with the correct headers.
		$response = new WP_REST_Response( null, 200 );
		$response->header( 'X-ZINV-PDF', (string) $id );
		$response->header( 'X-ZINV-PDF-DISPOSITION', $disposition );
		return $response;
	}

	/**
	 * Stream the invoice PDF on rest_pre_serve_request, bypassing JSON encoding.
	 *
	 * Triggered only for the /invoices/{id}/pdf route — identified by the
	 * X-ZINV-PDF header that get_pdf() sets. Returning true tells
	 * WP_REST_Server::serve_request() the response is already served.
	 *
	 * @param bool             $served  Whether the request was already served.
	 * @param WP_HTTP_Response $result  The response object.
	 * @param WP_REST_Request  $request The request object.
	 * @return bool
	 */
	public function maybe_serve_pdf( $served, $result, $request ) {
		if ( $served || ! ( $result instanceof WP_HTTP_Response ) ) {
			return $served;
		}

		$headers       = $result->get_headers();
		$headers_lower = array_change_key_case( (array) $headers, CASE_LOWER );

		if ( ! empty( $headers_lower['x-zinv-export-invoices'] ) ) {
			$status_raw  = sanitize_text_field( $request->get_param( 'status' ) ?? '' );
			$allowed     = [ '', 'all', 'draft', 'sent', 'viewed', 'paid', 'partial', 'overdue', 'void', 'in_recovery' ];
			$status      = in_array( $status_raw, $allowed, true ) ? ( $status_raw === 'all' ? '' : $status_raw ) : '';

			$args = [
				'page'          => 1,
				'per_page'      => 5000,
				'_max_per_page' => 5000,
				'search'        => sanitize_text_field( $request->get_param( 'search' ) ?? '' ),
				'status'        => $status,
				'client_id'     => absint( $request->get_param( 'client_id' ) ?? 0 ),
				'date_from'     => sanitize_text_field( $request->get_param( 'date_from' ) ?? '' ),
				'date_to'       => sanitize_text_field( $request->get_param( 'date_to' ) ?? '' ),
				'orderby'       => 'created_at',
				'order'         => 'DESC',
			];

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

			$csv_headers = [ 'Invoice Number', 'Client ID', 'Status', 'Issue Date', 'Due Date', 'Subtotal', 'Tax', 'Discount', 'Total', 'Amount Due', 'Currency', 'Notes' ];

			header( 'Content-Type: text/csv; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="invoices-' . 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 ( $invoices as $inv ) {
				$row = (array) $inv;
				fputcsv( $out, [
					$row['invoice_number'] ?? '',
					$row['client_id']      ?? '',
					$row['status']         ?? '',
					$row['issue_date']     ?? '',
					$row['due_date']       ?? '',
					$row['subtotal']       ?? 0,
					$row['tax_total']      ?? 0,
					$row['discount_total'] ?? 0,
					$row['total']          ?? 0,
					$row['amount_due']     ?? 0,
					$row['currency']       ?? '',
					ZINV_REST_Clients::sanitize_csv_value( (string) ( $row['notes'] ?? '' ) ),
				] );
			}

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

		if ( empty( $headers_lower['x-zinv-pdf'] ) ) {
			return $served;
		}

		$id          = absint( $headers_lower['x-zinv-pdf'] );
		$disposition = sanitize_text_field( (string) ( $headers_lower['x-zinv-pdf-disposition'] ?? 'inline' ) );
		$existing    = $this->invoice->get( $id );
		if ( ! $existing ) {
			return $served;
		}

		try {
			ZINV_PDF_Generator::stream( $id, $existing['template'] ?? 'clean-sheet', $disposition );
		} catch ( \Exception $e ) {
			// Log the actual exception for debugging
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( 'Zoventic Invoices PDF generation failed for invoice ' . $id . ': ' . $e->getMessage() );
			status_header( 500 );
			header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
			// Include the exception message in debug mode
			$error_data = [ 'status' => 500 ];
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				$error_data['debug'] = $e->getMessage();
			}
			echo wp_json_encode( [
				'code'    => 'zinv_pdf_generation_failed',
				'message' => __( 'Failed to generate PDF.', 'zoventic-invoices' ),
				'data'    => $error_data,
			] );
		}

		return true;
	}

	/**
	 * Validate a requested template slug against the Pro-aware allowlist.
	 *
	 * Returns null when the param is absent/empty (CRUD applies the default)
	 * or valid; a WP_Error otherwise.
	 *
	 * @param mixed $template Raw 'template' request param.
	 * @return WP_Error|null
	 */
	private function validate_template( $template ): ?WP_Error {
		if ( null === $template || '' === $template ) {
			return null;
		}

		$slug = sanitize_text_field( (string) $template );

		if ( ! class_exists( 'ZINV_PDF_Generator' ) || ! ZINV_PDF_Generator::is_valid_template( $slug ) ) {
			return new WP_Error(
				'zinv_invoice_invalid_template',
				__( 'The selected template is not available.', 'zoventic-invoices' ),
				[ 'status' => 400 ]
			);
		}

		return null;
	}

	private function validate_line_items( array &$line_items ): true|WP_Error {
		foreach ( $line_items as $index => &$item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$item['description'] = sanitize_text_field( $item['description'] ?? '' );
			$item['quantity']    = isset( $item['quantity'] )   ? (float) $item['quantity']   : 0;
			$item['unit_price']  = isset( $item['unit_price'] ) ? (float) $item['unit_price'] : 0;
			$item['tax_rate']    = isset( $item['tax_rate'] )   ? (float) $item['tax_rate']   : 0;

			$qty   = $item['quantity'];
			$price = $item['unit_price'];
			$tax   = $item['tax_rate'];

			if ( $qty < 0 ) {
				return new WP_Error(
					'zinv_line_item_invalid_quantity',
					sprintf(
						/* translators: %d: line item index (1-based) */
						__( 'Line item %d: quantity cannot be negative.', 'zoventic-invoices' ),
						$index + 1
					),
					[ 'status' => 400 ]
				);
			}

			if ( $price < 0 ) {
				return new WP_Error(
					'zinv_line_item_invalid_price',
					sprintf(
						/* translators: %d: line item index (1-based) */
						__( 'Line item %d: unit price cannot be negative.', 'zoventic-invoices' ),
						$index + 1
					),
					[ 'status' => 400 ]
				);
			}

			if ( $tax < 0 || $tax > 100 ) {
				return new WP_Error(
					'zinv_line_item_invalid_tax',
					sprintf(
						/* translators: %d: line item index (1-based) */
						__( 'Line item %d: tax rate must be between 0 and 100.', 'zoventic-invoices' ),
						$index + 1
					),
					[ 'status' => 400 ]
				);
			}
		}

		return true;
	}

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

	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 ) || $v === 'all' || in_array( $v, [ 'draft', 'sent', 'viewed', 'paid', 'partial', 'overdue', 'void', 'in_recovery' ], true ),
			],
			'client_id' => [
				'type'              => 'integer',
				'default'           => 0,
				'sanitize_callback' => 'absint',
			],
			'date_from' => [
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => empty( $v ) || (bool) \DateTime::createFromFormat( 'Y-m-d', $v ),
			],
			'date_to' => [
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => empty( $v ) || (bool) \DateTime::createFromFormat( 'Y-m-d', $v ),
			],
			'orderby' => [
				'type'              => 'string',
				'default'           => 'created_at',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => fn( $v ) => in_array( $v, [ 'id', 'invoice_number', 'client_id', 'status', 'issue_date', 'due_date', 'total', 'amount_due', '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 [
			'client_id' => [
				'type'              => 'integer',
				'required'          => true,
				'sanitize_callback' => 'absint',
				'validate_callback' => fn( $v ) => is_numeric( $v ) && absint( $v ) > 0,
			],
		];
	}

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