<?php
/**
 * Attachment staging for queued emails.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Queue;

defined( 'ABSPATH' ) || exit;

/**
 * Class AttachmentStager
 *
 * Copies attachments referenced by enqueued wp_mail() calls into a
 * dedicated staging directory so the runner can deliver them later even
 * if the original source files have been deleted (tmp dirs, request-scoped
 * artefacts like generated PDF invoices, etc.).
 *
 * @since 1.0.0
 */
class AttachmentStager {

    /**
     * Sub-path under the WordPress uploads directory used for staging.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const STAGING_SUBDIR = 'nimblymail-smtp-queue';

    /**
     * Get the absolute path to the staging directory for a queue row.
     *
     * @since 1.0.0
     *
     * @param int $queue_id The queue row ID.
     *
     * @return string Absolute filesystem path with no trailing slash.
     */
    public static function get_directory_for( int $queue_id ): string {
        $uploads = wp_upload_dir( null, false );
        $base    = rtrim( (string) $uploads['basedir'], '/\\' );
        return $base . '/' . self::STAGING_SUBDIR . '/' . $queue_id;
    }

    /**
     * Copy attachments into the staging directory for a queue row.
     *
     * Returned shape is an associative array `[display_name => staged_path]`
     * where `display_name` is the original basename the email recipient
     * should see and `staged_path` is the unguessable on-disk filename.
     * `wp_mail()` accepts that shape and forwards `display_name` through
     * `PHPMailer::addAttachment( $path, $name )`, so randomising the on-disk
     * filename does not leak through to the recipient.
     *
     * Source files that are missing or unreadable are skipped; the returned
     * map only includes successfully copied files. Callers can compare the
     * returned count to the original input length to detect partial staging.
     *
     * @since 1.0.0
     *
     * @param int                                $queue_id    The queue row ID.
     * @param string|string[]|array<int, string> $attachments Attachments as accepted by wp_mail().
     *
     * @return array<string, string> Display name => absolute staged path.
     */
    public static function stage( int $queue_id, $attachments ): array {
        if ( empty( $attachments ) ) {
            return array();
        }

        $list = is_array( $attachments ) ? $attachments : array( $attachments );

        $list = array_values(
            array_filter(
                array_map( 'strval', $list ),
                static function ( string $path ): bool {
                    return '' !== $path;
                }
            )
        );

        if ( empty( $list ) ) {
            return array();
        }

        $dir = self::get_directory_for( $queue_id );
        if ( ! wp_mkdir_p( $dir ) ) {
            return array();
        }

        self::lock_down_parent_directory( dirname( $dir ) );

        $allowed_roots = self::allowed_source_roots();

        $staged = array();
        foreach ( $list as $source ) {
            if ( ! is_readable( $source ) ) {
                continue;
            }

            // Resolve symlinks and require the real path live under one of
            // the allowed root directories. Without this, a buggy or hostile
            // caller could stage `/etc/passwd` (or anything the PHP user can
            // read) into the queue, where it would later be delivered as an
            // attachment. The allow-list defaults to the uploads directory
            // and the system temp directory; sites with stricter needs can
            // narrow it via the documented filter.
            $real_source = realpath( $source );
            if ( false === $real_source ) {
                continue;
            }
            if ( ! self::path_within_any( $real_source, $allowed_roots ) ) {
                continue;
            }

            $original = wp_basename( $real_source );
            $info     = pathinfo( $original );
            $ext      = isset( $info['extension'] ) ? '.' . preg_replace( '/[^A-Za-z0-9_-]/', '', $info['extension'] ) : '';

            // 32 hex chars = 128 bits of entropy. Unguessable; collisions
            // are astronomically unlikely so a uniqueness loop is overkill.
            $target = $dir . '/' . bin2hex( random_bytes( 16 ) ) . $ext;

            // copy() is intentional here: WP_Filesystem can require credential
            // prompts which are unavailable in cron/CLI contexts. Both source
            // and target are vetted (uploads dir, owned by us).
            // phpcs:ignore WordPress.WP.AlternativeFunctions.copy_copy
            if ( ! copy( $real_source, $target ) ) {
                continue;
            }

            // Preserve the recipient-visible filename. If two source files
            // share a basename (rare), suffix later occurrences so the map
            // does not silently drop attachments.
            $display = $original;
            $i       = 1;
            while ( isset( $staged[ $display ] ) ) {
                ++$i;
                $stem    = $info['filename'];
                $display = $stem . '-' . $i . $ext;
            }

            $staged[ $display ] = $target;
        }

        return $staged;
    }

    /**
     * Build the list of root directories an attachment source may live under.
     *
     * Defaults to: the WordPress uploads basedir, the system temp dir, and
     * `WP_CONTENT_DIR` — the locations the WordPress ecosystem legitimately
     * stages attachments from (uploads + temp PDFs/CSVs from plugins, plus
     * plugin-specific subdirectories under wp-content). Filterable via
     * `nimblymail_attachment_allowed_source_roots` so sites with stricter
     * policy can shrink (or extend) the list.
     *
     * @since 1.0.0
     * @since 1.0.2 Removed a redundant type check on the system temp directory lookup, which always returns a string.
     *
     * @return string[] Normalized absolute directory paths with no trailing slash.
     */
    private static function allowed_source_roots(): array {
        $roots = array();

        $uploads = wp_upload_dir( null, false );
        if ( ! empty( $uploads['basedir'] ) ) {
            $real = realpath( (string) $uploads['basedir'] );
            if ( false !== $real ) {
                $roots[] = self::normalize_path( $real );
            }
        }

        $tmp = sys_get_temp_dir();
        if ( '' !== $tmp ) {
            $real_tmp = realpath( $tmp );
            if ( false !== $real_tmp ) {
                $roots[] = self::normalize_path( $real_tmp );
            }
        }

        if ( defined( 'WP_CONTENT_DIR' ) ) {
            $real_content = realpath( (string) WP_CONTENT_DIR );
            if ( false !== $real_content ) {
                $roots[] = self::normalize_path( $real_content );
            }
        }

        /**
         * Filters the list of root directories from which queued attachments may be staged.
         *
         * Each entry must be an absolute path with no trailing slash. Paths
         * outside this allow-list will be silently skipped during staging.
         *
         * @since 1.0.0
         *
         * @param string[] $roots Normalized absolute paths.
         */
        $filtered = (array) apply_filters( 'nimblymail_attachment_allowed_source_roots', $roots );

        return array_values( array_unique( array_filter( array_map( 'strval', $filtered ) ) ) );
    }

    /**
     * Test whether $path resolves under any of the supplied root directories.
     *
     * @since 1.0.0
     *
     * @param string   $path     Absolute resolved path.
     * @param string[] $roots    Allowed root directories (normalized, no trailing slash).
     *
     * @return bool True when $path lives under at least one root.
     */
    private static function path_within_any( string $path, array $roots ): bool {
        $normalized = self::normalize_path( $path );

        foreach ( $roots as $root ) {
            if ( '' === $root ) {
                continue;
            }
            if ( $normalized === $root ) {
                return true;
            }
            if ( str_starts_with( $normalized, $root . '/' ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Normalise a filesystem path: forward slashes, no trailing slash.
     *
     * @since 1.0.0
     *
     * @param string $path Absolute path.
     *
     * @return string
     */
    private static function normalize_path( string $path ): string {
        return rtrim( str_replace( '\\', '/', $path ), '/' );
    }

    /**
     * Drop directory-deny markers in the parent staging dir on first use.
     *
     * The dir lives under `wp-content/uploads/`, which is web-served by
     * default. Without these markers an attacker who knows the plugin slug
     * could enumerate `<basedir>/nimblymail-smtp-queue/<n>/<name>` and fetch
     * attachments belonging to other emails. The on-disk filenames are now
     * also randomised, but defense in depth keeps the dir uncrawlable.
     *
     * @since 1.0.0
     *
     * @param string $parent_dir The parent staging directory (no trailing slash).
     *
     * @return void
     */
    private static function lock_down_parent_directory( string $parent_dir ): void {
        $marker = $parent_dir . '/.htaccess';
        if ( ! file_exists( $marker ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            file_put_contents(
                $marker,
                "Order deny,allow\nDeny from all\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n"
            );
        }

        $iis_marker = $parent_dir . '/web.config';
        if ( ! file_exists( $iis_marker ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            file_put_contents(
                $iis_marker,
                "<configuration>\n  <system.webServer>\n    <authorization>\n      <deny users=\"*\" />\n    </authorization>\n  </system.webServer>\n</configuration>\n"
            );
        }

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

    /**
     * Remove the staging directory for a queue row.
     *
     * Idempotent: returns true even when the directory does not exist.
     *
     * @since 1.0.0
     *
     * @param int $queue_id The queue row ID.
     *
     * @return bool True on success or when the directory was already absent.
     */
    public static function cleanup( int $queue_id ): bool {
        $dir = self::get_directory_for( $queue_id );

        if ( ! is_dir( $dir ) ) {
            return true;
        }

        $entries = scandir( $dir );
        if ( false === $entries ) {
            return false;
        }

        foreach ( $entries as $entry ) {
            if ( '.' === $entry || '..' === $entry ) {
                continue;
            }
            $path = $dir . '/' . $entry;
            if ( is_file( $path ) || is_link( $path ) ) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
                unlink( $path );
            }
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
        return rmdir( $dir );
    }
}
