<?php
/**
 * Encrypted PDF storage.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Invoices;

defined( 'ABSPATH' ) || exit;

/**
 * Handles encrypted storage and retrieval of PDF documents.
 *
 * Uses libsodium (XSalsa20-Poly1305) for encryption at rest.
 * Directory protected via .htaccess, web.config, and index.php stubs.
 *
 * @since 1.0.0
 */
final class Storage {

	private const UPLOAD_DIR  = 'kramar-invoices';
	private const OPTION_KEY  = 'kramar_invoice_key';
	private const NONCE_BYTES = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;

	/**
	 * Get the base upload directory path.
	 *
	 * @since 1.0.0
	 *
	 * @return string Absolute path with trailing slash.
	 */
	public static function get_base_dir(): string {
		$upload = wp_get_upload_dir();
		return trailingslashit( $upload['basedir'] ) . self::UPLOAD_DIR . '/';
	}

	/**
	 * Get the year-based subdirectory for current date.
	 *
	 * @since 1.0.0
	 *
	 * @return string Absolute path with trailing slash.
	 */
	public static function get_year_dir(): string {
		return self::get_base_dir() . current_time( 'Y' ) . '/';
	}

	/**
	 * Ensure the storage directory exists with protection files.
	 *
	 * @since 1.0.0
	 */
	public static function ensure_directory(): void {
		$base = self::get_base_dir();
		$year = self::get_year_dir();

		foreach ( [ $base, $year ] as $dir ) {
			if ( ! is_dir( $dir ) ) {
				wp_mkdir_p( $dir );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- WP_Filesystem has no chmod equivalent for post-creation permission hardening
				chmod( $dir, 0750 );
			}
		}

		if ( ! file_exists( $base . 'index.php' ) ) {
			file_put_contents( $base . 'index.php', "<?php\n// Silence is golden.\n" );
		}

		if ( ! file_exists( $base . '.htaccess' ) ) {
			file_put_contents(
				$base . '.htaccess',
				"<IfModule mod_authz_core.c>\n  Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n  Order deny,allow\n  Deny from all\n</IfModule>\n"
			);
		}

		if ( ! file_exists( $base . 'web.config' ) ) {
			file_put_contents(
				$base . 'web.config',
				"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n  <system.webServer>\n    <authorization>\n      <deny users=\"*\" />\n    </authorization>\n  </system.webServer>\n</configuration>\n"
			);
		}

		if ( ! file_exists( $year . 'index.php' ) ) {
			file_put_contents( $year . 'index.php', "<?php\n// Silence is golden.\n" );
		}
	}

	/**
	 * Get or generate the encryption key.
	 *
	 * @since 1.0.0
	 *
	 * @return string Raw binary key.
	 */
	private static function get_key(): string {
		$key = get_option( self::OPTION_KEY, '' );

		if ( '' === $key ) {
			$raw = sodium_crypto_secretbox_keygen();
			$key = base64_encode( $raw );
			update_option( self::OPTION_KEY, $key, false );
			return $raw;
		}

		return base64_decode( $key );
	}

	/**
	 * Encrypt and store a PDF file.
	 *
	 * @since 1.0.0
	 *
	 * @param string $pdf_content Raw PDF binary content.
	 * @param string $type        Document type ('invoice', 'packing-slip', 'credit-note').
	 * @param string $number      Formatted document number.
	 * @return string Relative path from base dir (stored in order meta).
	 */
	public static function store( string $pdf_content, string $type, string $number ): string {
		self::ensure_directory();

		$key   = self::get_key();
		$nonce = random_bytes( self::NONCE_BYTES );
		$enc   = $nonce . sodium_crypto_secretbox( $pdf_content, $nonce, $key );

		$hex      = bin2hex( random_bytes( 4 ) );
		$safe_num = preg_replace( '/[^a-zA-Z0-9\-]/', '', $number );
		$filename = $type . '-' . $safe_num . '-' . $hex . '.enc';
		$year     = current_time( 'Y' );
		$rel_path = $year . '/' . $filename;
		$abs_path = self::get_base_dir() . $rel_path;

		file_put_contents( $abs_path, $enc );

		sodium_memzero( $key );

		return $rel_path;
	}

	/**
	 * Retrieve and decrypt a stored PDF.
	 *
	 * @since 1.0.0
	 *
	 * @param string $rel_path Relative path from store().
	 * @return string|null Raw PDF content, or null on failure.
	 */
	public static function retrieve( string $rel_path ): ?string {
		$abs_path = self::get_base_dir() . $rel_path;

		if ( ! file_exists( $abs_path ) ) {
			return null;
		}

		$enc = file_get_contents( $abs_path );
		if ( false === $enc || strlen( $enc ) < self::NONCE_BYTES + 1 ) {
			return null;
		}

		$key   = self::get_key();
		$nonce = substr( $enc, 0, self::NONCE_BYTES );
		$ct    = substr( $enc, self::NONCE_BYTES );

		try {
			$pdf = sodium_crypto_secretbox_open( $ct, $nonce, $key );
		} catch ( \SodiumException $e ) {
			sodium_memzero( $key );
			return null;
		}

		sodium_memzero( $key );

		return false === $pdf ? null : $pdf;
	}

	/**
	 * Delete a stored encrypted file.
	 *
	 * @since 1.0.0
	 *
	 * @param string $rel_path Relative path.
	 */
	public static function delete( string $rel_path ): void {
		$abs_path = self::get_base_dir() . $rel_path;

		if ( file_exists( $abs_path ) ) {
			wp_delete_file( $abs_path );
		}
	}

	/**
	 * Serve a PDF to the browser with proper headers.
	 *
	 * @since 1.0.0
	 *
	 * @param string $rel_path Relative path from order meta.
	 * @param string $filename Display filename for download (e.g. "invoice-0001.pdf").
	 * @param bool   $inline   True to display inline, false to force download.
	 */
	public static function serve( string $rel_path, string $filename, bool $inline = false ): void {
		$pdf = self::retrieve( $rel_path );

		if ( null === $pdf ) {
			wp_die( esc_html__( 'Document not found.', 'kramar' ), 404 );
		}

		$disposition = $inline ? 'inline' : 'attachment';

		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: ' . $disposition . '; filename="' . sanitize_file_name( $filename ) . '"' );
		header( 'Content-Length: ' . strlen( $pdf ) );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $pdf;
		exit;
	}

	/**
	 * Write a decrypted PDF to a temp file (for email attachments).
	 *
	 * Registers a shutdown function to clean up the temp file.
	 *
	 * @since 1.0.0
	 *
	 * @param string $rel_path Relative path from order meta.
	 * @param string $filename Display filename.
	 * @return string|null Absolute path to temp file, or null on failure.
	 */
	public static function to_temp_file( string $rel_path, string $filename ): ?string {
		$pdf = self::retrieve( $rel_path );

		if ( null === $pdf ) {
			return null;
		}

		$tmp = wp_tempnam( $filename );
		file_put_contents( $tmp, $pdf );

		register_shutdown_function( static function () use ( $tmp ): void {
			if ( file_exists( $tmp ) ) {
				wp_delete_file( $tmp );
			}
		} );

		return $tmp;
	}

	/**
	 * Delete all stored files (for uninstall).
	 *
	 * @since 1.0.0
	 */
	public static function delete_all(): void {
		$base = self::get_base_dir();

		if ( ! is_dir( $base ) ) {
			return;
		}

		$files = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $base, \FilesystemIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);

		foreach ( $files as $file ) {
			if ( $file->isDir() ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- recursive directory removal via iterator; WP_Filesystem not suitable here
				rmdir( $file->getRealPath() );
			} else {
				wp_delete_file( $file->getRealPath() );
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- removing top-level base dir after recursive cleanup; WP_Filesystem not suitable here
		rmdir( $base );
	}
}
