<?php
/**
 * Secure storage dla załączników KSeF.
 *
 * Pliki trzymane w `wp-content/uploads/ksef-private/{invoice_type}/{invoice_id}/`.
 * Katalog chroniony `.htaccess` (Apache/LiteSpeed) + index.php blank — bez
 * możliwości pobrania po bezpośrednim URL. Serwowanie wyłącznie przez
 * Plugin::ajax_attachment_download (sprawdza cap + nonce + ownership).
 *
 * @package KSEF\WordPress
 */

declare(strict_types=1);

namespace KSEF\WordPress;

final class Secure_Storage {

	private const SUBDIR = 'ksef-private';

	public static function base_dir(): string {
		// Polegamy wyłącznie na wp_upload_dir() (obsługuje multisite i niestandardowe
		// lokalizacje uploadów). Bez twardego fallbacku na stałą katalogu wp-content.
		$uploads = wp_upload_dir();
		return rtrim( (string) ( $uploads['basedir'] ?? '' ), '/' ) . '/' . self::SUBDIR;
	}

	/**
	 * Proaktywnie tworzy bazowy katalog z .htaccess (wywoływane raz przy bootstrapie).
	 * Nie czekaj na pierwszy upload.
	 */
	public static function bootstrap_base(): void {
		$base = self::base_dir();
		if ( ! is_dir( $base ) ) {
			wp_mkdir_p( $base );
		}
		self::write_protection_files( $base );
	}

	/**
	 * Tworzy katalog (rekursywnie) + dodaje pliki ochronne.
	 */
	public static function ensure_dir( string $relative ): string {
		$path = self::base_dir() . '/' . trim( $relative, '/' );
		if ( ! is_dir( $path ) ) {
			wp_mkdir_p( $path );
		}
		self::write_protection_files( self::base_dir() );
		// W każdym podkatalogu też index.php blank (przeciw directory listing fallback).
		$index_sub = $path . '/index.php';
		if ( ! file_exists( $index_sub ) ) {
			@file_put_contents( $index_sub, "<?php // silence\n" );
		}
		return $path;
	}

	/**
	 * Wpisuje wzmocniony .htaccess + index.php blank do bazowego katalogu.
	 * Idempotentne — nadpisuje tylko gdy treść jest INNA niż oczekiwana.
	 */
	private static function write_protection_files( string $base ): void {
		$wanted_htaccess = "# KSeF private attachments — deny direct access\n"
			. "# Działa dla Apache, LiteSpeed, OpenLiteSpeed.\n"
			. "Options -Indexes\n"
			. "<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"
			// Dodatkowa warstwa — match wszystkich plików, jeśli powyższe nie zadziała.
			. "<FilesMatch \".*\">\n"
			. "  <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"
			. "</FilesMatch>\n";
		$htaccess = $base . '/.htaccess';
		if ( ! file_exists( $htaccess ) || @file_get_contents( $htaccess ) !== $wanted_htaccess ) {
			@file_put_contents( $htaccess, $wanted_htaccess );
		}
		$index_blank = $base . '/index.php';
		if ( ! file_exists( $index_blank ) ) {
			@file_put_contents( $index_blank, "<?php // silence\n" );
		}
		$index_html = $base . '/index.html';
		if ( ! file_exists( $index_html ) ) {
			@file_put_contents( $index_html, '' );
		}
	}

	/**
	 * Przenosi uploadowany plik z $tmp_path do bezpiecznej lokacji.
	 *
	 * @param string $tmp_path     ścieżka $_FILES['file']['tmp_name']
	 * @param string $invoice_type 'incoming' | 'outgoing'
	 * @param int    $invoice_id
	 * @param string $original     oryginalna nazwa pliku
	 * @return array{path:string,mime:string,size:int}|\WP_Error path = relatywny od base_dir()
	 */
	public static function store_upload( string $tmp_path, string $invoice_type, int $invoice_id, string $original ): array|\WP_Error {
		if ( ! is_uploaded_file( $tmp_path ) && ! is_file( $tmp_path ) ) {
			return new \WP_Error( 'ksef_storage_no_tmp', 'Brak pliku tymczasowego.' );
		}
		// Sanityzacja invoice_type
		$invoice_type = in_array( $invoice_type, [ 'incoming', 'outgoing' ], true ) ? $invoice_type : 'incoming';
		if ( $invoice_id <= 0 ) {
			return new \WP_Error( 'ksef_storage_bad_id', 'Niepoprawny invoice_id.' );
		}

		// Walidacja typu MIME — biała lista (faktury i dokumenty rozliczeniowe).
		$allowed_mime = [
			'application/pdf'  => 'pdf',
			'image/jpeg'       => 'jpg',
			'image/png'        => 'png',
			'image/webp'       => 'webp',
			'image/heic'       => 'heic',
			'application/xml'  => 'xml',
			'text/xml'         => 'xml',
			'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
			'application/vnd.ms-excel' => 'xls',
			'application/msword' => 'doc',
			'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
		];
		$mime = self::detect_mime( $tmp_path, $original );
		if ( ! isset( $allowed_mime[ $mime ] ) ) {
			return new \WP_Error( 'ksef_storage_bad_mime', 'Niedozwolony typ pliku (' . esc_html( $mime ) . '). Dozwolone: PDF, JPG, PNG, WEBP, HEIC, XML, XLS/XLSX, DOC/DOCX.' );
		}
		$ext = $allowed_mime[ $mime ];

		// Druga, niezależna walidacja zgodna z rdzeniem WordPress: krzyżowo sprawdza
		// rozszerzenie nazwy pliku z realnym typem (chroni przed plikami „polyglot",
		// np. obraz będący jednocześnie skryptem PHP). Realny typ (finfo) musi być na
		// białej liście POWYŻEJ; tu odrzucamy dodatkowo dodatni rozjazd ext↔typ.
		$wp_mimes = [
			'pdf'      => 'application/pdf',
			'jpg|jpeg' => 'image/jpeg',
			'png'      => 'image/png',
			'webp'     => 'image/webp',
			'heic'     => 'image/heic',
			'xml'      => 'application/xml',
			'xls'      => 'application/vnd.ms-excel',
			'xlsx'     => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
			'doc'      => 'application/msword',
			'docx'     => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
		];
		$wp_check = wp_check_filetype_and_ext( $tmp_path, $original, $wp_mimes );
		if ( ! empty( $wp_check['type'] ) && ! isset( $allowed_mime[ (string) $wp_check['type'] ] ) ) {
			return new \WP_Error( 'ksef_storage_bad_mime', 'Niedozwolony typ pliku.' );
		}

		$size = (int) filesize( $tmp_path );
		// Limit 25 MB
		if ( $size > 25 * 1024 * 1024 ) {
			return new \WP_Error( 'ksef_storage_too_big', 'Plik większy niż 25 MB.' );
		}

		// Bezpieczna nazwa: TYLKO hash randomowy, BEZ rozszerzenia. Bez `.pdf`/`.jpg`
		// LiteSpeed/Apache nie wie jak go serwować i nie zwraca jako Content-Type
		// pdf/image — minimalizuje ryzyko gdy htaccess byłby pominięty.
		// Prawdziwe rozszerzenie + MIME zapisujemy w bazie i restorujemy
		// w Content-Disposition przy pobieraniu przez nasz endpoint.
		$filename = bin2hex( random_bytes( 16 ) ) . '-' . bin2hex( random_bytes( 8 ) );
		unset( $ext ); // rozszerzenie niepotrzebne w filename

		$rel_dir = $invoice_type . '/' . $invoice_id;
		$abs_dir = self::ensure_dir( $rel_dir );
		$abs_path = $abs_dir . '/' . $filename;

		// Przeniesienie pliku natywnym mechanizmem WordPressa (wp_handle_upload): rdzeń
		// weryfikuje, że plik pochodzi z żądania POST, bezpiecznie go przenosi i przepuszcza
		// przez własną walidację typu. Kierujemy zapis do prywatnego katalogu wtyczki filtrem
		// upload_dir i wymuszamy losową nazwę bez rozszerzenia (unique_filename_callback).
		if ( ! function_exists( 'wp_handle_upload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		$ksef_upload_dir = static function ( array $dirs ) use ( $abs_dir ): array {
			$dirs['path']   = $abs_dir;
			$dirs['url']    = '';
			$dirs['subdir'] = '';
			return $dirs;
		};
		$ksef_force_name = static function () use ( $filename ): string {
			return $filename;
		};
		add_filter( 'upload_dir', $ksef_upload_dir );
		$handled = wp_handle_upload(
			[
				'name'     => $original,
				'type'     => $mime,
				'tmp_name' => $tmp_path,
				'error'    => 0,
				'size'     => $size,
			],
			[
				'test_form'                => false,
				'mimes'                    => $wp_mimes,
				'unique_filename_callback' => $ksef_force_name,
			]
		);
		remove_filter( 'upload_dir', $ksef_upload_dir );
		if ( ! is_array( $handled ) || ! empty( $handled['error'] ) || empty( $handled['file'] ) || ! is_file( (string) $handled['file'] ) ) {
			return new \WP_Error( 'ksef_storage_move_fail', 'Nie udało się zapisać pliku.' );
		}
		$abs_path = (string) $handled['file'];
		@chmod( $abs_path, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- lokalne operacje na wlasnych plikach wtyczki (cache/upload).

		return [
			'path' => $rel_dir . '/' . $filename,
			'mime' => $mime,
			'size' => $size,
		];
	}

	public static function full_path( string $relative ): string {
		return self::base_dir() . '/' . ltrim( $relative, '/' );
	}

	public static function delete( string $relative ): bool {
		$path = self::full_path( $relative );
		if ( ! is_file( $path ) ) {
			return true;
		}
		// Bezpieczeństwo: ścieżka musi być WEWNĄTRZ base_dir
		$real_base = realpath( self::base_dir() );
		$real_path = realpath( $path );
		if ( ! $real_path || ! $real_base || strpos( $real_path, $real_base ) !== 0 ) {
			return false;
		}
		return (bool) @unlink( $real_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- lokalne operacje na wlasnych plikach wtyczki (cache/upload).
	}

	/**
	 * Streamuj plik do przeglądarki z poprawnymi nagłówkami.
	 * Wywoływane przez Plugin::ajax_attachment_download — wymaga już zweryfikowanej uprawy.
	 */
	public static function stream( string $relative, string $download_name, string $mime ): void {
		$path = self::full_path( $relative );
		// Sanity check — żeby Path Traversal nie wyciągnął nic spoza katalogu.
		$real_base = realpath( self::base_dir() );
		$real_path = realpath( $path );
		if ( ! $real_path || ! $real_base || strpos( $real_path, $real_base ) !== 0 || ! is_file( $real_path ) ) {
			status_header( 404 );
			exit;
		}
		// Nagłówki HTTP.
		while ( ob_get_level() ) { ob_end_clean(); }
		header( 'Content-Type: ' . ( $mime ?: 'application/octet-stream' ) );
		header( 'Content-Length: ' . (string) filesize( $real_path ) );
		header( 'Content-Disposition: attachment; filename="' . self::sanitize_disposition( $download_name ) . '"' );
		header( 'X-Content-Type-Options: nosniff' );
		header( 'Cache-Control: private, no-store, must-revalidate' );
		header( 'Pragma: no-cache' );
		readfile( $real_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- lokalne operacje na wlasnych plikach wtyczki (cache/upload).
		exit;
	}

	private static function detect_mime( string $path, string $original_name ): string {
		// finfo jest najpewniejsze.
		if ( function_exists( 'finfo_open' ) ) {
			$f = finfo_open( FILEINFO_MIME_TYPE );
			if ( $f ) {
				$m = finfo_file( $f, $path );
				finfo_close( $f );
				if ( $m ) {
					return (string) $m;
				}
			}
		}
		// Fallback: po rozszerzeniu.
		$ext = strtolower( (string) pathinfo( $original_name, PATHINFO_EXTENSION ) );
		$map = [
			'pdf'  => 'application/pdf',
			'jpg'  => 'image/jpeg', 'jpeg' => 'image/jpeg',
			'png'  => 'image/png',
			'webp' => 'image/webp',
			'heic' => 'image/heic',
			'xml'  => 'application/xml',
			'xls'  => 'application/vnd.ms-excel',
			'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
			'doc'  => 'application/msword',
			'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
		];
		return $map[ $ext ] ?? 'application/octet-stream';
	}

	private static function sanitize_disposition( string $name ): string {
		$name = preg_replace( '/[\x00-\x1F\x7F"]/', '', $name );
		return (string) substr( (string) $name, 0, 200 );
	}
}
