<?php
/**
 * Email Resender.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Log;

use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Queue\AttachmentStager;

defined( 'ABSPATH' ) || exit;

/**
 * Class Resender
 *
 * Reconstructs and re-sends an email from a stored log entry.
 *
 * @since 1.0.0
 */
class Resender {

    /**
     * Pattern a single header line must match before it is forwarded to wp_mail().
     *
     * Catches CRLF injection (the line is implicitly single-line because the
     * input is split / trimmed beforehand) and rejects anything that does not
     * look like a syntactic RFC-822 field-name + value.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const HEADER_LINE_PATTERN = '/^[A-Za-z][A-Za-z0-9-]*:\s.+$/';

    /**
     * Resend a single log entry via wp_mail().
     *
     * Reconstructs the original wp_mail() arguments from the log row and
     * dispatches a new send. A new log entry is created by the Logger hook
     * automatically.
     *
     * Header lines and attachment paths are revalidated before forwarding to
     * wp_mail() so that a row tampered with at the database layer (rogue
     * SQL, compromised sibling plugin) cannot be turned into a header
     * injection or an arbitrary-file attachment exfiltration vector via the
     * admin "Resend" action.
     *
     * @since 1.0.0
     *
     * @param int $log_id The ID of the log entry to resend.
     * @return bool True on success, false on failure.
     */
    public static function resend( int $log_id ): bool {
        $row = Model::find_one( $log_id );

        if ( ! $row ) {
            return false;
        }

        $to          = $row['to_address'] ?? '';
        $subject     = $row['subject'] ?? '';
        $message     = $row['body'] ?? '';
        $headers     = self::decode_headers( $row['headers'] ?? '' );
        $attachments = self::decode_attachments( $row['attachments'] ?? '' );

        if ( empty( $to ) ) {
            return false;
        }

        $sent = (bool) wp_mail( $to, $subject, $message, $headers, $attachments );

        if ( $sent ) {
            return true;
        }

        // wp_mail() returns false when delivery is intentionally suppressed —
        // "Do Not Send" (Misc) or the Developer Sandbox in Log Only mode. The
        // message was still handled and logged (as 'blocked' / 'sandboxed'), so
        // report the resend as handled rather than a hard failure. Sandbox
        // Redirect mode performs a real send to the safe address, so a false
        // there is a genuine delivery failure and must NOT be masked.
        $options     = Options::init();
        $sandbox_log = $options->get( 'sandbox', 'enabled' ) && 'log' === $options->get( 'sandbox', 'mode' );
        if ( $options->get( 'general', 'do_not_send' ) || $sandbox_log ) {
            return true;
        }

        return false;
    }

    /**
     * Bulk resend multiple log entries.
     *
     * @since 1.0.0
     *
     * @param int[] $ids Array of log IDs.
     * @return array { success: int, failed: int }
     */
    public static function bulk_resend( array $ids ): array {
        $result = array(
            'success' => 0,
            'failed'  => 0,
        );

        foreach ( $ids as $id ) {
            if ( self::resend( (int) $id ) ) {
                ++$result['success'];
            } else {
                ++$result['failed'];
            }
        }

        return $result;
    }

    /**
     * Decode and validate stored headers before forwarding to wp_mail().
     *
     * Both the JSON-decoded array path and the legacy newline-split fallback
     * funnel through the same per-line validator. Lines that do not match
     * `^[A-Za-z][A-Za-z0-9-]*:\s.+$` (after trimming) are dropped so a row
     * containing CR-LF injection or non-string entries cannot pass through.
     *
     * @since 1.0.0
     *
     * @param string $raw JSON string or plain header string.
     *
     * @return string[] Validated header lines, in original order.
     */
    private static function decode_headers( string $raw ): array {
        if ( '' === $raw ) {
            return array();
        }

        $decoded = json_decode( $raw, true );

        if ( is_array( $decoded ) ) {
            return self::filter_header_lines( $decoded );
        }

        return self::filter_header_lines( explode( "\n", $raw ) );
    }

    /**
     * Validate a list of header lines, dropping anything malformed.
     *
     * @since 1.0.0
     *
     * @param array<int|string, mixed> $lines Candidate header lines.
     *
     * @return string[] Validated lines.
     */
    private static function filter_header_lines( array $lines ): array {
        $valid = array();

        foreach ( $lines as $line ) {
            if ( ! is_string( $line ) ) {
                continue;
            }

            $trimmed = trim( $line );
            if ( '' === $trimmed ) {
                continue;
            }

            if ( false !== strpos( $trimmed, "\r" ) || false !== strpos( $trimmed, "\n" ) ) {
                continue;
            }

            if ( preg_match( self::HEADER_LINE_PATTERN, $trimmed ) ) {
                $valid[] = $trimmed;
            }
        }

        return $valid;
    }

    /**
     * Decode JSON-encoded attachments, allow-listing paths against trusted directories.
     *
     * Without containment, anyone with write access to the logs table could
     * point `attachments` at `/etc/passwd` (or any file the PHP process can
     * read) and turn an admin "Resend" click into local-file disclosure.
     * Allowed roots: the WordPress uploads basedir and the queue staging
     * subdirectory under it. Symlinks are resolved with realpath() and the
     * resolved path is required to live under one of the allowed roots.
     *
     * @since 1.0.0
     *
     * @param string $raw JSON string.
     * @return string[] Validated absolute paths.
     */
    private static function decode_attachments( string $raw ): array {
        if ( '' === $raw ) {
            return array();
        }

        $decoded = json_decode( $raw, true );

        if ( ! is_array( $decoded ) ) {
            return array();
        }

        $allowed_roots = self::allowed_attachment_roots();

        $valid = array();
        foreach ( $decoded as $candidate ) {
            $path = is_string( $candidate ) ? $candidate : '';
            if ( '' === $path ) {
                continue;
            }

            $resolved = realpath( $path );
            if ( false === $resolved ) {
                continue;
            }

            $resolved = self::normalize_path( $resolved );

            foreach ( $allowed_roots as $root ) {
                if ( '' === $root ) {
                    continue;
                }
                if ( str_starts_with( $resolved, $root ) ) {
                    $valid[] = $resolved;
                    continue 2;
                }
            }
        }

        return $valid;
    }

    /**
     * Build the allow-list of directories an attachment path may resolve into.
     *
     * @since 1.0.0
     *
     * @return string[] Normalized directory paths with a trailing slash.
     */
    private static function allowed_attachment_roots(): array {
        $roots = array();

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

        // The queue staging directory lives under uploads, but we resolve it
        // explicitly so removing the staging dir from the upload allow-list
        // (e.g. by a stricter Pro policy) still covers the runner's use case.
        $stager = AttachmentStager::get_directory_for( 0 );
        $parent = dirname( $stager );
        $real   = realpath( $parent );
        if ( false !== $real ) {
            $roots[] = self::normalize_path( $real ) . '/';
        }

        /**
         * Filter the list of root directories an attachment path is allowed to
         * resolve into when resending logged emails.
         *
         * @since 1.0.0
         *
         * @param string[] $roots Normalized absolute paths with trailing slashes.
         */
        $filtered = (array) apply_filters( 'nimblymail_resend_attachment_allowed_roots', $roots );

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

    /**
     * Normalize a filesystem path to forward slashes with 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 ), '/' );
    }
}
