<?php
/**
 * Evidence filed with a claim.
 *
 * @package WarrantyCart
 */

namespace WarrantyCart\Core;

use WP_Error;

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

/**
 * Files a customer attaches to a claim.
 *
 * Every warranty process starts with evidence — a photograph of the crack, the
 * receipt, the serial plate. Until this existed a claim carried a subject and a
 * description, so the evidence arrived by email, outside the plugin, attached to
 * nothing.
 *
 * ## Why not the media library
 *
 * Because a claim photograph is somebody's living room, and a receipt is their
 * address. `wp_handle_upload()` into `wp-content/uploads` produces a public URL
 * that needs no authentication and is trivially enumerable — the file is served
 * by the web server before WordPress is involved.
 *
 * These files go to their own directory, which is closed to direct requests, and
 * are served by `REST\Controllers\Claims` after it has checked who is asking.
 * The cost is that they do not appear in the media library; that is the point.
 */
class ClaimAttachments {

	/** Directory under the uploads basedir. */
	public const DIRECTORY = 'warranty-cart-claims';

	/**
	 * The table.
	 *
	 * @return string
	 */
	public static function table(): string {
		global $wpdb;

		return $wpdb->prefix . 'warranty_cart_claim_attachments';
	}

	/**
	 * What a customer may attach.
	 *
	 * Images and PDFs. Not archives, not documents that execute — the file is
	 * evidence to be looked at, and every additional type is another parser to
	 * be wrong about.
	 *
	 * @return array<string, string> Extension => MIME type.
	 */
	public static function allowed_types(): array {
		/**
		 * Filters the file types a claim accepts.
		 *
		 * @since 2.1.0
		 *
		 * @param array<string, string> $types Extension => MIME type.
		 */
		return (array) apply_filters(
			'warranty_cart_claim_attachment_types',
			array(
				'jpg|jpeg' => 'image/jpeg',
				'png'      => 'image/png',
				'gif'      => 'image/gif',
				'webp'     => 'image/webp',
				'heic'     => 'image/heic',
				'pdf'      => 'application/pdf',
			)
		);
	}

	/**
	 * Where the mandatory-proof setting is stored.
	 */
	public const PROOF_OPTION = 'warranty_require_claim_proof';

	/**
	 * Where the claim upload ceiling is stored.
	 */
	public const MAX_SIZE_OPTION = 'warranty_cart_claim_max_file_size';

	/**
	 * Where the per-claim file count is stored.
	 */
	public const MAX_FILES_OPTION = 'warranty_cart_claim_attachment_limit';

	/**
	 * The size ceiling, in bytes.
	 *
	 * This used to read `warranty_max_file_size`, which is the CSV importer's
	 * limit and is labelled as such on the settings screen. One number governed
	 * two unrelated things: a shop tightening it to keep imports small silently
	 * stopped customers attaching photographs of a fault, and a shop wanting
	 * bigger claim photographs had to go and change a setting filed under
	 * Import.
	 *
	 * The CSV value is the fallback, so a store that had raised or lowered it
	 * keeps the ceiling it chose until it sets one here.
	 *
	 * @return int
	 */
	public static function max_bytes(): int {
		$megabytes = (int) get_option( self::MAX_SIZE_OPTION, (int) get_option( 'warranty_max_file_size', 5 ) );

		if ( $megabytes < 1 ) {
			$megabytes = 5;
		}

		return $megabytes * MB_IN_BYTES;
	}

	/**
	 * How many files one claim may carry.
	 *
	 * @return int
	 */
	public static function max_per_claim(): int {
		$configured = (int) get_option( self::MAX_FILES_OPTION, 5 );

		/**
		 * Filters the number of attachments a claim accepts.
		 *
		 * @since 2.1.0
		 *
		 * @param int $max Maximum files per claim.
		 */
		return max( 1, (int) apply_filters( 'warranty_cart_claim_attachment_limit', $configured > 0 ? $configured : 5 ) );
	}

	/**
	 * Whether a claim cannot be filed without evidence.
	 *
	 * @return bool
	 */
	public static function proof_required(): bool {
		// Stored as '1'/'0' by the settings controller, like every other
		// boolean setting; (bool) '0' is false, which is the point of that.
		return (bool) get_option( self::PROOF_OPTION, false );
	}

	/**
	 * The directory these files live in, created and closed off on first use.
	 *
	 * @return array{path: string, error: string}
	 */
	public static function directory(): array {
		$uploads = wp_upload_dir();

		if ( ! empty( $uploads['error'] ) ) {
			return array(
				'path'  => '',
				'error' => (string) $uploads['error'],
			);
		}

		$path = trailingslashit( $uploads['basedir'] ) . self::DIRECTORY;

		if ( ! wp_mkdir_p( $path ) ) {
			return array(
				'path'  => '',
				'error' => __( 'The upload directory could not be created.', 'warranty-cart' ),
			);
		}

		self::protect( $path );

		return array(
			'path'  => trailingslashit( $path ),
			'error' => '',
		);
	}

	/**
	 * Close the directory to direct requests.
	 *
	 * `.htaccess` covers Apache. `index.php` stops a directory listing anywhere.
	 * Neither helps on nginx, which ignores both — so the REST route is the
	 * guarantee and these are defence in depth, not the defence.
	 *
	 * @param string $path Absolute directory path.
	 *
	 * @return void
	 */
	protected static function protect( string $path ): void {
		$htaccess = trailingslashit( $path ) . '.htaccess';

		if ( ! file_exists( $htaccess ) ) {
			@file_put_contents( $htaccess, "Order deny,allow\nDeny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions, Generic.PHP.NoSilencedErrors
		}

		$index = trailingslashit( $path ) . 'index.php';

		if ( ! file_exists( $index ) ) {
			@file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions, Generic.PHP.NoSilencedErrors
		}
	}

	/**
	 * Store one uploaded file against a claim.
	 *
	 * @param int                                                                        $claim_id The claim.
	 * @param array{name: string, type: string, tmp_name: string, error: int, size: int} $file     One entry from $_FILES.
	 * @param int                                                                        $user_id  Who uploaded it.
	 *
	 * @return int|WP_Error The attachment row id.
	 */
	public static function store( int $claim_id, array $file, int $user_id ) {
		if ( ! empty( $file['error'] ) ) {
			return new WP_Error(
				'warranty_cart_attachment_upload_error',
				__( 'The file could not be uploaded.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		if ( (int) $file['size'] > self::max_bytes() ) {
			return new WP_Error(
				'warranty_cart_attachment_too_large',
				sprintf(
					/* translators: %s: maximum file size, e.g. "5 MB". */
					__( 'Files must be %s or smaller.', 'warranty-cart' ),
					size_format( self::max_bytes() )
				),
				array( 'status' => 400 )
			);
		}

		/*
		 * The claimed type is not evidence of anything.
		 *
		 * `$file['type']` is whatever the browser said. `wp_check_filetype_and_ext()`
		 * reads the file itself and reports what it actually is, which is the
		 * only version worth checking against the allow list.
		 */
		$checked = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'], self::allowed_types() );

		if ( empty( $checked['ext'] ) || empty( $checked['type'] ) ) {
			return new WP_Error(
				'warranty_cart_attachment_type',
				__( 'Attach a photograph or a PDF.', 'warranty-cart' ),
				array( 'status' => 400 )
			);
		}

		$directory = self::directory();

		if ( '' !== $directory['error'] ) {
			return new WP_Error(
				'warranty_cart_attachment_directory',
				$directory['error'],
				array( 'status' => 500 )
			);
		}

		/*
		 * The stored name is ours, not theirs.
		 *
		 * A filename from a browser is attacker-controlled; generating one
		 * removes every question about traversal, double extensions and
		 * collisions in a single stroke. The name the customer used is kept in
		 * the database for display.
		 */
		$stored_name = sprintf(
			'claim-%d-%s.%s',
			$claim_id,
			wp_generate_password( 20, false, false ),
			strtok( (string) $checked['ext'], '|' )
		);

		$destination = $directory['path'] . $stored_name;

		if ( ! @move_uploaded_file( $file['tmp_name'], $destination ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions, Generic.PHP.NoSilencedErrors
			return new WP_Error(
				'warranty_cart_attachment_move_failed',
				__( 'The file could not be saved.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		@chmod( $destination, 0644 ); // phpcs:ignore WordPress.WP.AlternativeFunctions, Generic.PHP.NoSilencedErrors

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$inserted = $wpdb->insert(
			self::table(),
			array(
				'claim_id'      => $claim_id,
				'stored_name'   => $stored_name,
				'original_name' => sanitize_file_name( (string) $file['name'] ),
				'mime_type'     => (string) $checked['type'],
				'file_size'     => (int) $file['size'],
				'uploaded_by'   => $user_id,
				'created_at'    => current_time( 'mysql' ),
			),
			array( '%d', '%s', '%s', '%s', '%d', '%d', '%s' )
		);

		if ( ! $inserted ) {
			wp_delete_file( $destination );

			return new WP_Error(
				'warranty_cart_attachment_failed',
				__( 'The file could not be saved.', 'warranty-cart' ),
				array( 'status' => 500 )
			);
		}

		return (int) $wpdb->insert_id;
	}

	/**
	 * Store a whole upload payload, stopping at the first refusal.
	 *
	 * @param int   $claim_id The claim.
	 * @param array $files    `$request->get_file_params()`.
	 * @param int   $user_id  Who uploaded them.
	 *
	 * @return int|WP_Error How many were stored.
	 */
	public static function store_many( int $claim_id, array $files, int $user_id ) {
		$flat = self::flatten( $files );

		if ( empty( $flat ) ) {
			return 0;
		}

		if ( count( $flat ) > self::max_per_claim() ) {
			return new WP_Error(
				'warranty_cart_attachment_limit',
				sprintf(
					/* translators: %d: maximum number of files. */
					_n(
						'Attach at most %d file.',
						'Attach at most %d files.',
						self::max_per_claim(),
						'warranty-cart'
					),
					self::max_per_claim()
				),
				array( 'status' => 400 )
			);
		}

		$stored = 0;

		foreach ( $flat as $file ) {
			$result = self::store( $claim_id, $file, $user_id );

			if ( is_wp_error( $result ) ) {
				return $result;
			}

			++$stored;
		}

		return $stored;
	}

	/**
	 * Turn `$_FILES` into a flat list, whichever shape it arrived in.
	 *
	 * A single input gives one entry; an `attachments[]` input gives arrays per
	 * key, which is the shape that trips people up.
	 *
	 * @param array $files `$_FILES`-shaped input.
	 *
	 * @return array<int, array{name: string, type: string, tmp_name: string, error: int, size: int}>
	 */
	protected static function flatten( array $files ): array {
		$flat = array();

		foreach ( $files as $entry ) {
			if ( ! is_array( $entry ) || ! isset( $entry['name'] ) ) {
				continue;
			}

			if ( ! is_array( $entry['name'] ) ) {
				if ( '' !== $entry['name'] ) {
					$flat[] = $entry;
				}

				continue;
			}

			foreach ( array_keys( $entry['name'] ) as $index ) {
				if ( '' === $entry['name'][ $index ] ) {
					continue;
				}

				$flat[] = array(
					'name'     => $entry['name'][ $index ],
					'type'     => $entry['type'][ $index ] ?? '',
					'tmp_name' => $entry['tmp_name'][ $index ] ?? '',
					'error'    => $entry['error'][ $index ] ?? UPLOAD_ERR_NO_FILE,
					'size'     => $entry['size'][ $index ] ?? 0,
				);
			}
		}

		return $flat;
	}

	/**
	 * Everything attached to a claim.
	 *
	 * @param int $claim_id The claim.
	 *
	 * @return array<int, object>
	 */
	public static function for_claim( int $claim_id ): array {
		global $wpdb;

		$table = self::table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE claim_id = %d ORDER BY id ASC", $claim_id )
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * One attachment row.
	 *
	 * @param int $attachment_id The row.
	 *
	 * @return object|null
	 */
	public static function get( int $attachment_id ): ?object {
		global $wpdb;

		$table = self::table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $attachment_id )
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery

		return $row ? $row : null;
	}

	/**
	 * Where an attachment lives on disk.
	 *
	 * `basename()` on the stored name because a row is only ever written by
	 * `store()`, which generates it — but a path built from a database column is
	 * worth pinning to one directory regardless.
	 *
	 * @param object $attachment The row.
	 *
	 * @return string Absolute path, or '' if it is missing.
	 */
	public static function path( object $attachment ): string {
		$uploads = wp_upload_dir();

		if ( ! empty( $uploads['error'] ) ) {
			return '';
		}

		$path = trailingslashit( $uploads['basedir'] ) . self::DIRECTORY . '/' . basename( (string) $attachment->stored_name );

		return file_exists( $path ) ? $path : '';
	}

	/**
	 * Delete every file belonging to a claim, and their rows.
	 *
	 * @param int $claim_id The claim.
	 *
	 * @return void
	 */
	public static function delete_for_claim( int $claim_id ): void {
		foreach ( self::for_claim( $claim_id ) as $attachment ) {
			$path = self::path( $attachment );

			if ( '' !== $path ) {
				wp_delete_file( $path );
			}
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->delete( self::table(), array( 'claim_id' => $claim_id ), array( '%d' ) );
	}
}
