<?php
/**
 * Invoice persistence.
 *
 * @package Harbor_EU_EInvoicing_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class HEIN_Invoice_Store {

	private const CANARY = 'hein-canary.txt';

	/**
	 * The LONGBLOB row is canonical and the uploads file only a mirror, so protection does not
	 * depend on .htaccess, which is a no-op on nginx. One upsert against the order_format unique
	 * key: a SELECT-then-INSERT has the same race as the counter. The already-sent read above is a
	 * check-then-act, so the upsert also refuses in SQL to overwrite a 'sent' status/remote_id: a
	 * Generate racing a transmission must never reset a sent invoice and re-enable a duplicate send
	 *
	 * @throws HEIN_Invoice_Exception When the row was transmitted, or the write failed.
	 */
	public static function save( int $order_id, array $invoice, string $format, array $rendered ): array {
		global $wpdb;

		$existing = self::get( $order_id, $format );

		if ( null !== $existing && 'sent' === $existing['status'] ) {
			throw new HEIN_Invoice_Exception(
				esc_html__(
					'This invoice has already been transmitted and cannot be replaced.',
					'harbor-eu-einvoicing-for-woocommerce'
				),
				'already_sent'
			);
		}

		$content   = (string) ( $rendered['content'] ?? '' );
		$extension = (string) ( $rendered['extension'] ?? 'xml' );
		$filename  = self::filename( $invoice, $order_id, $format, $extension );
		$now       = current_time( 'mysql', true );
		$table     = hein_invoices_table();

		$result = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"INSERT INTO {$table}
				(order_id, format, invoice_number, issue_date, currency, payable_total, mime, filename,
				content, content_hash, file_path, status, remote_id, created_gmt, updated_gmt)
				VALUES (%d, %s, %s, %s, %s, %f, %s, %s, %s, %s, %s, 'generated', '', %s, %s)
				ON DUPLICATE KEY UPDATE
				invoice_number = VALUES(invoice_number), issue_date = VALUES(issue_date),
				currency = VALUES(currency), payable_total = VALUES(payable_total),
				mime = VALUES(mime), filename = VALUES(filename), content = VALUES(content),
				content_hash = VALUES(content_hash), file_path = VALUES(file_path),
				status = IF(status = 'sent', status, VALUES(status)),
				remote_id = IF(status = 'sent', remote_id, VALUES(remote_id)),
				updated_gmt = VALUES(updated_gmt)",
				$order_id,
				$format,
				(string) ( $invoice['invoice_number'] ?? '' ),
				(string) ( $invoice['issue_date'] ?? '' ),
				(string) ( $invoice['currency'] ?? '' ),
				(float) ( $invoice['totals']['payable'] ?? 0 ),
				(string) ( $rendered['mime'] ?? 'application/xml' ),
				$filename,
				$content,
				hash( 'sha256', $content ),
				self::mirror( $order_id, $format, $filename, $content ),
				$now,
				$now
			)
		);

		$row = false === $result ? null : self::get( $order_id, $format );

		// A multi-megabyte PDF over max_allowed_packet must not report success
		if ( null === $row ) {
			throw new HEIN_Invoice_Exception(
				esc_html__( 'The invoice could not be saved to the database.', 'harbor-eu-einvoicing-for-woocommerce' ),
				'store_failed'
			);
		}

		return $row;
	}

	public static function get( int $order_id, string $format ): ?array {
		global $wpdb;

		$table = hein_invoices_table();

		$row = $wpdb->get_row(
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d AND format = %s", $order_id, $format ),
			ARRAY_A
		);

		return is_array( $row ) && $row ? $row : null;
	}

	public static function list_for_order( int $order_id ): array {
		global $wpdb;

		$table = hein_invoices_table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, order_id, format, invoice_number, issue_date, currency, payable_total, mime,
				filename, content_hash, file_path, status, remote_id, created_gmt, updated_gmt
				FROM {$table} WHERE order_id = %d ORDER BY format ASC",
				$order_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return is_array( $rows ) ? $rows : [];
	}

	public static function mark_sent( int $id, string $remote_id ): void {
		global $wpdb;

		$wpdb->update(
			hein_invoices_table(),
			[
				'status'      => 'sent',
				'remote_id'   => $remote_id,
				'updated_gmt' => current_time( 'mysql', true ),
			],
			[ 'id' => $id ],
			[ '%s', '%s', '%s' ],
			[ '%d' ]
		);
	}

	public static function dir(): string {
		$uploads = wp_upload_dir();

		return trailingslashit( (string) $uploads['basedir'] ) . 'hein-invoices/';
	}

	public static function filename( array $invoice, int $order_id, string $format, string $extension ): string {
		$hash = substr( hash_hmac( 'sha256', $order_id . '|' . $format, wp_salt( 'hein_invoice' ) ), 0, 8 );

		$name = sprintf(
			'%s-%d-%s.%s',
			(string) ( $invoice['invoice_number'] ?? '' ),
			$order_id,
			$hash,
			$extension
		);

		return sanitize_file_name( (string) apply_filters( 'hein_invoice_filename', $name, $order_id, $format ) );
	}

	/**
	 * Rewritten before every mirror write, so a wiped directory self-heals. The canary is what the
	 * exposure probe looks for: without it the probe measures nothing and the warning never fires
	 */
	public static function ensure_protected(): void {
		$dir = self::dir();

		if ( ! wp_mkdir_p( $dir ) ) {
			return;
		}

		$htaccess = "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n"
			. "<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n";

		$webconfig = "<?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";

		$files = [
			'.htaccess'  => $htaccess,
			'web.config' => $webconfig,
			'index.html' => '',
			self::CANARY => "hein\n",
		];

		foreach ( $files as $name => $contents ) {
			file_put_contents( $dir . $name, $contents );
		}
	}

	/**
	 * A WP_Error means the loopback is blocked, which is UNKNOWN and must not be reported as safe
	 */
	public static function is_publicly_exposed(): ?bool {
		if ( empty( HEIN_Admin_Settings::get( 'store_files', 1 ) ) ) {
			return false;
		}

		$cached = get_transient( 'hein_uploads_exposed' );

		if ( false !== $cached ) {
			return 'unknown' === $cached ? null : 'yes' === $cached;
		}

		self::ensure_protected();

		$response = wp_remote_head( self::url() . self::CANARY, [ 'timeout' => 5 ] );
		$exposed  = is_wp_error( $response ) ? null : 200 === (int) wp_remote_retrieve_response_code( $response );

		set_transient(
			'hein_uploads_exposed',
			null === $exposed ? 'unknown' : ( $exposed ? 'yes' : 'no' ),
			DAY_IN_SECONDS
		);

		return $exposed;
	}

	private static function url(): string {
		$uploads = wp_upload_dir();

		return trailingslashit( (string) $uploads['baseurl'] ) . 'hein-invoices/';
	}

	private static function mirror( int $order_id, string $format, string $filename, string $content ): string {
		$enabled = ! empty( HEIN_Admin_Settings::get( 'store_files', 1 ) );
		$enabled = (bool) apply_filters( 'hein_store_to_filesystem', $enabled, $order_id, $format );

		if ( ! $enabled ) {
			return '';
		}

		self::ensure_protected();

		$path = self::dir() . $filename;

		return false !== file_put_contents( $path, $content ) ? $path : '';
	}
}
