<?php
/**
 * Service for managing order invoice generation and retrieval.
 *
 * @package PDFDraft_WooCommerce
 */

namespace PDFDraft\Woocommerce\Core;

use PDFDraft\Services\StorageService;
use WP_Error;

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

/**
 * Handles invoice PDF generation, storage, validation, and download.
 */
class OrderInvoiceService {

	/**
	 * Default invoice storage folder name (used when no custom folder is configured).
	 *
	 * @var string
	 */
	private const INVOICE_FOLDER = 'invoices';

	/**
	 * Meta key for the full relative storage path (folder + filename).
	 *
	 * @var string
	 */
	private const META_INVOICE_FULL_PATH = 'pdfdraft_invoice_full_path';

	/**
	 * Minimal valid PDF content for placeholder invoices.
	 *
	 * @var string
	 */
	private const MINIMAL_PDF = "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n178\n%%EOF\n";

	/**
	 * Meta key for invoice generated flag.
	 *
	 * @var string
	 */
	private const META_IS_GENERATED = 'pdfdraft_is_invoice_generated';

	/**
	 * Meta key for invoice number.
	 *
	 * @var string
	 */
	private const META_INVOICE_NUMBER = 'pdfdraft_invoice_number';

	/**
	 * Meta key for invoice file path.
	 *
	 * @var string
	 */
	private const META_INVOICE_PATH = 'pdfdraft_invoice_path';

	/**
	 * Meta key for invoice storage folder.
	 *
	 * @var string
	 */
	private const META_INVOICE_FOLDER = 'pdfdraft_invoice_folder';

	/**
	 * Checkout upload token meta (must match DownloadInvoiceController).
	 *
	 * @var string
	 */
	private const META_UPLOAD_TOKEN = 'pdfdraft_checkout_upload_token';

	/**
	 * Checkout upload fingerprint meta (must match DownloadInvoiceController).
	 *
	 * @var string
	 */
	private const META_UPLOAD_FINGERPRINT = 'pdfdraft_checkout_upload_fingerprint';

	/**
	 * Storage service instance.
	 *
	 * @var StorageService
	 */
	private StorageService $storage;

	/**
	 * PDF settings option instance.
	 *
	 * @var PdfSettingsOption
	 */
	private PdfSettingsOption $settings;

	/**
	 * Constructor.
	 *
	 * @param StorageService    $storage  Storage service instance.
	 * @param PdfSettingsOption $settings PDF settings option instance.
	 */
	public function __construct(
		StorageService $storage,
		PdfSettingsOption $settings
	) {
		$this->storage  = $storage;
		$this->settings = $settings;
	}

	/**
	 * Check whether a real (non-placeholder) invoice has been generated for an order.
	 *
	 * Returns true only when the META_IS_GENERATED flag was explicitly set by
	 * saveClientPdf() — i.e. a real, user-generated PDF has been uploaded and
	 * saved for this order.  The placeholder written by generateForOrder() does
	 * NOT set this flag, so this method correctly returns false while only the
	 * stub exists.
	 *
	 * @param int $orderId Order ID.
	 * @return bool True if a real invoice PDF exists.
	 */
	public function isGenerated( int $orderId ): bool {
		$order = wc_get_order( $orderId );
		if ( ! $order instanceof \WC_Order ) {
			return false;
		}
		$val = $order->get_meta( self::META_IS_GENERATED );
		if ( 'true' !== $val && true !== $val ) {
			return false;
		}

		$relativePath = $this->getInvoicePathForOrder( $orderId );
		if ( null === $relativePath ) {
			return false;
		}
		$fullPath = $this->storage->getBasePath() . $relativePath;
		if ( ! is_file( $fullPath ) || ! is_readable( $fullPath ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Check whether an invoice slot has been reserved for an order (placeholder
	 * or real PDF).  Unlike isGenerated(), this returns true as soon as
	 * generateForOrder() has run, even before a real PDF has been uploaded.
	 *
	 * Use this when you only need to know whether an invoice path/number has
	 * already been assigned (e.g. to avoid creating a second placeholder).
	 *
	 * @param int $orderId Order ID.
	 * @return bool True if an invoice path is stored for this order.
	 */
	public function isReserved( int $orderId ): bool {
		return null !== $this->getInvoicePathForOrder( $orderId );
	}

	/**
	 * Get the full filesystem path to an order invoice.
	 *
	 * @param int $orderId Order ID.
	 * @return string|null Full path or null if not available.
	 */
	public function getFullInvoicePathForOrder( int $orderId ): ?string {
		$relative = $this->getInvoicePathForOrder( $orderId );
		if ( null === $relative ) {
			return null;
		}
		$full = $this->storage->getBasePath() . $relative;
		return ( is_file( $full ) && is_readable( $full ) ) ? $full : null;
	}

	/**
	 * Get the relative storage path for an order invoice.
	 *
	 * @param int $orderId Order ID.
	 * @return string|null Relative path or null if not set.
	 */
	public function getInvoicePathForOrder( int $orderId ): ?string {
		$order = wc_get_order( $orderId );
		if ( ! $order instanceof \WC_Order ) {
			return null;
		}
		$folder   = (string) $order->get_meta( self::META_INVOICE_FOLDER );
		$filename = (string) $order->get_meta( self::META_INVOICE_PATH );
		if ( '' === $filename ) {
			return null;
		}
		// Folder may be empty string when files are stored in the root storage directory.
		return ( '' !== $folder ? $folder . '/' : '' ) . $filename;
	}

	/**
	 * Build the storage folder path based on current settings.
	 *
	 * Resulting path segments (all relative to the storage base):
	 *   - Custom folder (if set)
	 *   - Year/month subdirectory (if organize-by-date is enabled)
	 *
	 * @return string Relative folder path with no leading/trailing slash.
	 *                Empty string means store in the root folder.
	 */
	private function buildStorageFolder(): string {
		$settings      = $this->settings->get();
		$custom_folder = (string) ( $settings['general']['invoice_settings']['storage_custom_folder'] ?? '' );
		$by_date       = (bool) ( $settings['general']['invoice_settings']['storage_organize_by_date'] ?? false );

		$parts = array();
		if ( '' !== $custom_folder ) {
			$parts[] = $custom_folder;
		}
		if ( $by_date ) {
			$parts[] = gmdate( 'Y' );
			$parts[] = gmdate( 'm' );
		}

		return implode( '/', $parts );
	}

	/**
	 * Protect a storage subfolder from direct web access by placing .htaccess
	 * and index.html files if they do not already exist.
	 *
	 * @param string $folder Relative folder path (e.g. 'my-invoices/2025/04').
	 * @return void
	 */
	private function protectStorageFolder( string $folder ): void {
		if ( '' === $folder ) {
			return; // Root folder is already protected by the core plugin.
		}

		$base = rtrim( $this->storage->getBasePath(), '/' ) . '/' . $folder . '/';

		$htaccess = $base . '.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $htaccess, "deny from all\n" );
		}

		$index = $base . 'index.html';
		if ( ! file_exists( $index ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			file_put_contents( $index, '' );
		}
	}

	/**
	 * Build the invoice filename for an order.
	 *
	 * @param \WC_Order $order WooCommerce order object.
	 * @return string Sanitized invoice filename.
	 */
	private function buildInvoiceFilename( \WC_Order $order ): string {
		$prefix   = 'Invoice';
		$settings = $this->settings->get();
		if ( isset( $settings['general']['invoice_settings']['filename_prefix'] ) && '' !== $settings['general']['invoice_settings']['filename_prefix'] ) {
			$prefix = $settings['general']['invoice_settings']['filename_prefix'];
		}
		$fallback = $prefix . '_' . $order->get_id() . '.pdf';
		$filename = (string) apply_filters( 'pdfdraft_pdf_invoices_for_woocommerce_invoice_filename', $fallback, $order );

		// Strip null bytes, sanitize, and enforce basename to prevent directory traversal.
		$filename = str_replace( "\0", '', $filename );
		$filename = basename( sanitize_file_name( $filename ) );

		// Enforce .pdf extension and reject excessively long names.
		if ( '' === $filename || strlen( $filename ) > 255 || 'pdf' !== strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) ) {
			$filename = $fallback;
		}

		return $filename;
	}

	/**
	 * Generate a placeholder invoice PDF for an order.
	 *
	 * Reserves the invoice number and storage path without marking the invoice as
	 * fully generated — isGenerated() will return false until saveClientPdf()
	 * overwrites the placeholder with a real PDF.
	 *
	 * @param \WC_Order $order WooCommerce order object.
	 * @return string|WP_Error Relative path on success, WP_Error on failure.
	 */
	public function generateForOrder( \WC_Order $order ) {
		$filename     = $this->buildInvoiceFilename( $order );
		$folder       = $this->buildStorageFolder();
		$relativePath = ( '' !== $folder ? $folder . '/' : '' ) . $filename;

		$content = self::MINIMAL_PDF;

		if ( ! $this->storage->save( $relativePath, $content ) ) {
			return new WP_Error( 'pdfdraft_invoice_save_failed', __( 'Could not save invoice file.', 'pdfdraft-pdf-invoices-for-woocommerce' ), array( 'status' => 500 ) );
		}

		$this->protectStorageFolder( $folder );
		// Pass false: do NOT set META_IS_GENERATED for the placeholder.
		// isGenerated() must only return true after a real PDF is uploaded via saveClientPdf().
		$this->updateOrderMeta( $order, $filename, $folder, false );

		return $relativePath;
	}

	/**
	 * Save a client-uploaded PDF for an order.
	 *
	 * @param \WC_Order $order   WooCommerce order object.
	 * @param string    $pdfData Raw PDF binary data.
	 * @return string|WP_Error Relative path on success, WP_Error on failure.
	 */
	public function saveClientPdf( \WC_Order $order, string $pdfData ) {
		if ( strlen( $pdfData ) < 1024 || '%PDF-' !== substr( $pdfData, 0, 5 ) ) {
			return new WP_Error(
				'pdfdraft_invalid_pdf',
				__( 'Uploaded data is not a valid PDF.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$tail = substr( $pdfData, -2048 );
		if ( false === strpos( $tail, '%%EOF' ) && false === strpos( $tail, '%EOF' ) ) {
			return new WP_Error(
				'pdfdraft_invalid_pdf',
				__( 'Uploaded data is not a valid PDF.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$maxSize = (int) apply_filters( 'pdfdraft_pdf_invoices_for_woocommerce_invoice_max_upload_size', 10 * 1024 * 1024 );
		if ( strlen( $pdfData ) > $maxSize ) {
			return new WP_Error(
				'pdfdraft_pdf_too_large',
				__( 'PDF exceeds the maximum allowed size.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Verify actual MIME type to prevent non-PDF files with a faked header.
		if ( function_exists( 'finfo_open' ) ) {
			$finfo = new \finfo( FILEINFO_MIME_TYPE );
			$mime  = $finfo->buffer( $pdfData );
			if ( 'application/pdf' !== $mime ) {
				return new WP_Error(
					'pdfdraft_invalid_mime',
					__( 'Uploaded file MIME type is not application/pdf.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
					array( 'status' => 400 )
				);
			}
		}

		// Scan for polyglot/embedded payloads (PHP, JS, HTML, SVG).
		if ( preg_match( '/<\?php|<script[\s>]|<svg[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i', $pdfData ) ) {
			return new WP_Error(
				'pdfdraft_suspicious_content',
				__( 'PDF contains suspicious embedded content.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$filename     = $this->buildInvoiceFilename( $order );
		$folder       = $this->buildStorageFolder();
		$relativePath = ( '' !== $folder ? $folder . '/' : '' ) . $filename;

		if ( ! $this->storage->save( $relativePath, $pdfData ) ) {
			return new WP_Error(
				'pdfdraft_invoice_save_failed',
				__( 'Could not save invoice file.', 'pdfdraft-pdf-invoices-for-woocommerce' ),
				array( 'status' => 500 )
			);
		}

		$this->protectStorageFolder( $folder );
		// Pass true: real PDF has been saved — mark the invoice as fully generated
		// so that isGenerated() returns true and email attachment works.
		$this->updateOrderMeta( $order, $filename, $folder, true );

		return $relativePath;
	}

	/**
	 * Update order meta data after invoice generation.
	 *
	 * @param \WC_Order $order         WooCommerce order object.
	 * @param string    $filename      Invoice filename (basename only).
	 * @param string    $folder        Relative folder path (no leading/trailing slash).
	 * @param bool      $markGenerated Whether to set the META_IS_GENERATED flag.
	 *                                 Pass false when saving a placeholder so that
	 *                                 isGenerated() keeps returning false until a
	 *                                 real PDF is uploaded via saveClientPdf().
	 * @return void
	 */
	private function updateOrderMeta( \WC_Order $order, string $filename, string $folder, bool $markGenerated = true ): void {
		$relative_full = ( '' !== $folder ? $folder . '/' : '' ) . $filename;

		// Build the full wp-content-relative path for display / reference.
		$uploads_dir  = wp_upload_dir();
		$storage_base = trailingslashit( $this->storage->getBasePath() );
		$full_path    = $storage_base . $relative_full;

		// Express as wp-content/uploads/... relative path (human-readable).
		$uploads_base = trailingslashit( $uploads_dir['basedir'] );
		$human_path   = 'wp-content/uploads/' . ltrim( str_replace( $uploads_base, '', $full_path ), '/' );

		if ( $markGenerated ) {
			$order->update_meta_data( self::META_IS_GENERATED, 'true' );
		}
		$order->update_meta_data( self::META_INVOICE_NUMBER, $order->get_order_number() );
		$order->update_meta_data( self::META_INVOICE_PATH, $filename );
		$order->update_meta_data( self::META_INVOICE_FOLDER, $folder );
		$order->update_meta_data( self::META_INVOICE_FULL_PATH, $human_path );
		$order->save();
	}

	/**
	 * Send the order completion email for an order.
	 *
	 * @param int $orderId Order ID.
	 * @return bool True if the email was sent successfully.
	 */
	public function sendCompletionEmail( int $orderId ): bool {
		$order = wc_get_order( $orderId );
		if ( ! $order instanceof \WC_Order ) {
			return false;
		}
		$mailer = WC()->mailer();
		$emails = $mailer->get_emails();
		if ( ! isset( $emails['WC_Email_Customer_Completed_Order'] ) ) {
			return false;
		}
		$emails['WC_Email_Customer_Completed_Order']->trigger( $orderId, $order );
		return true;
	}

	/**
	 * Download the invoice for an order as a REST response.
	 *
	 * @param int $orderId Order ID.
	 * @return \WP_REST_Response|WP_Error REST response with PDF data or error.
	 */
	public function downloadForOrder( int $orderId ) {
		$relativePath = $this->getInvoicePathForOrder( $orderId );
		if ( null === $relativePath ) {
			return new WP_Error( 'pdfdraft_invoice_not_generated', __( 'Invoice has not been generated yet.', 'pdfdraft-pdf-invoices-for-woocommerce' ), array( 'status' => 404 ) );
		}

		$fullPath = $this->storage->getBasePath() . $relativePath;
		if ( ! is_file( $fullPath ) || ! is_readable( $fullPath ) ) {
			return new WP_Error( 'pdfdraft_invoice_not_found', __( 'Invoice file not found.', 'pdfdraft-pdf-invoices-for-woocommerce' ), array( 'status' => 404 ) );
		}

		$order    = wc_get_order( $orderId );
		$filename = $order instanceof \WC_Order ? $this->buildInvoiceFilename( $order ) : basename( $relativePath );
		$filename = sanitize_file_name( $filename );

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$content = @file_get_contents( $fullPath );
		if ( false === $content ) {
			return new WP_Error( 'pdfdraft_invoice_read_error', __( 'Could not read invoice file.', 'pdfdraft-pdf-invoices-for-woocommerce' ), array( 'status' => 500 ) );
		}

		return new \WP_REST_Response(
			array(
				'filename' => $filename,
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
				'pdf'      => base64_encode( $content ),
			),
			200
		);
	}

	/**
	 * Remove the stored invoice file and all related order meta.
	 *
	 * @param \WC_Order $order Order.
	 * @return true
	 */
	public function removeInvoiceForOrder( \WC_Order $order ) {
		$orderId  = $order->get_id();
		$relative = $this->getInvoicePathForOrder( $orderId );
		if ( null !== $relative ) {
			$this->deleteInvoiceFileIfExists( $relative );
		}

		$order->delete_meta_data( self::META_IS_GENERATED );
		$order->delete_meta_data( self::META_INVOICE_NUMBER );
		$order->delete_meta_data( self::META_INVOICE_PATH );
		$order->delete_meta_data( self::META_INVOICE_FOLDER );
		$order->delete_meta_data( self::META_INVOICE_FULL_PATH );
		$order->delete_meta_data( self::META_UPLOAD_TOKEN );
		$order->delete_meta_data( self::META_UPLOAD_FINGERPRINT );
		$order->save();

		VirtualOrderEmailSuppressor::clearPendingEmailAndCron( $orderId );

		return true;
	}

	/**
	 * Delete a file under the invoice storage folder if it exists.
	 *
	 * @param string $relativePath Path relative to storage base (e.g. invoices/foo.pdf).
	 * @return void
	 */
	private function deleteInvoiceFileIfExists( string $relativePath ): void {
		$relativePath = str_replace( '\\', '/', $relativePath );
		$relativePath = trim( $relativePath, '/' );
		// Reject empty paths or anything with directory traversal sequences.
		if ( '' === $relativePath || false !== strpos( $relativePath, '..' ) ) {
			return;
		}

		$base = rtrim( $this->storage->getBasePath( '' ), '/' );
		$full = $base . '/' . $relativePath;

		$uploadsBase = wp_upload_dir()['basedir'];
		$realFull    = realpath( $full ) ?: $full;
		$realUploads = realpath( $uploadsBase ) ?: $uploadsBase;

		// Case-insensitive containment check (Windows) — must stay under uploads.
		if ( 0 !== stripos( $realFull, $realUploads ) ) {
			return;
		}
		if ( is_file( $full ) ) {
			wp_delete_file( $full );
		}
	}
}
