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

namespace NimblyMail\SMTP\Log;

use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Sandbox\SandboxInterceptor;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Class Logger
 *
 * Captures email data before and after wp_mail().
 *
 * @since 1.0.0
 * @since 1.0.5 capture_email() now writes created_at explicitly as GMT.
 */
class Logger {

    /**
     * Current log entry ID being tracked.
     *
     * @since 1.0.0
     *
     * @var int|null
     */
    private static ?int $current_log_id = null;

    /**
     * Timestamp when the current send operation started.
     *
     * @since 1.0.0
     *
     * @var float|null
     */
    private static ?float $send_start_time = null;

    /**
     * Accumulates debug output for the current email.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private static string $current_debug_log = '';

    /**
     * Source identifier detected during the current send operation.
     *
     * Cached here so Processor::handle_success() can include it in the
     * nimblymail_mail_sent payload without running detect_source() again.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private static string $current_source = '';

    /**
     * Pending queue row id supplied by Queue\Manager before short-circuit.
     *
     * Set during the nimblymail_pre_send handler when an email is enqueued.
     * Consumed (and cleared) by capture_email() so the inserted log row carries
     * the queue_id link. Per-request scope; never persists across requests.
     *
     * @since 1.0.0
     *
     * @var int|null
     */
    private static ?int $pending_queue_id = null;

    /**
     * Existing log row id supplied by Queue\Runner before re-invoking wp_mail().
     *
     * When set, capture_email() reuses this id as the current log id and flips
     * its status to processing instead of inserting a brand-new row. Cleared by
     * capture_email() the moment it consumes the value so the next unrelated
     * send falls back to insert behaviour.
     *
     * @since 1.0.0
     *
     * @var int|null
     */
    private static ?int $resume_log_id = null;


    /**
     * Initialize logging hooks.
     */
    public function init(): void {
        if ( ! Options::init()->get( 'logs', 'enabled' ) ) {
            return;
        }

        add_filter( 'wp_mail', array( $this, 'capture_email' ), PHP_INT_MAX );
        add_action( 'wp_mail_failed', array( $this, 'mark_failure' ) );
        add_action( 'wp_mail_succeeded', array( $this, 'mark_success' ) );
    }

    /**
     * Capture email data before sending.
     *
     * @since 1.0.0
     * @since 1.0.5 Writes created_at explicitly via current_time( 'mysql', true )
     *              instead of relying on the created_at column's DEFAULT
     *              CURRENT_TIMESTAMP - MySQL's CURRENT_TIMESTAMP follows the
     *              server connection's session time_zone (commonly SYSTEM,
     *              which is not guaranteed to be UTC), so leaving it to the
     *              database made created_at's actual timezone host-dependent.
     *              Every other timestamp column in this plugin already writes
     *              via current_time( 'mysql', true ); this makes created_at
     *              consistent so Model::find()'s get_date_from_gmt() conversion
     *              is correct on every host.
     *
     * @param array $args wp_mail arguments.
     * @return array
     */
    public function capture_email( array $args ): array {
        $parsed  = $this->parse_headers( $args['headers'] ?? array() );
        $options = Options::init();

        $to = '';
        if ( isset( $args['to'] ) ) {
            $to = is_array( $args['to'] ) ? implode( ', ', $args['to'] ) : $args['to'];
        }

        $source = $this->detect_source();

        $log_data = array(
            'to_address'   => $to,
            'subject'      => mb_substr( $args['subject'] ?? '', 0, 255 ),
            'body'         => $options->get( 'logs', 'log_body' ) ? ( $args['message'] ?? '' ) : null,
            'headers'      => wp_json_encode( $args['headers'] ?? array() ),
            'attachments'  => wp_json_encode( $args['attachments'] ?? array() ),
            'cc'           => $parsed['cc'] ?? null,
            'bcc'          => $parsed['bcc'] ?? null,
            'content_type' => $parsed['content_type'] ?? 'text/html',
            'status'       => 'pending',
            'source'       => $source,
            'mailer_used'  => $options->get( 'mail', 'mailer' ),
            'created_at'   => current_time( 'mysql', true ),
        );

        if ( null !== self::$pending_queue_id ) {
            $log_data['queue_id']   = (int) self::$pending_queue_id;
            self::$pending_queue_id = null;
        }

        // Apply sandbox decision (set by SandboxInterceptor::maybe_rewrite at
        // wp_mail filter priority 1, before this filter at PHP_INT_MAX).
        $sandbox_decision = SandboxInterceptor::peek_decision();
        if ( 'log' === $sandbox_decision ) {
            $log_data['status']       = 'sandboxed';
            $log_data['sandbox_mode'] = 'log';
        } elseif ( 'redirect' === $sandbox_decision ) {
            $log_data['sandbox_mode'] = 'redirect';
        } elseif ( 'bypassed' === $sandbox_decision ) {
            $log_data['sandbox_mode'] = 'bypassed';
        }

        // Do Not Send blocks delivery globally for direct (non-queued) sends:
        // Processor short-circuits pre_wp_mail (after this filter), so neither
        // wp_mail_succeeded nor wp_mail_failed fires to terminate the row.
        // Record the terminal "blocked" status here, taking precedence over the
        // sandbox decision. Queued sends are handled earlier by Queue\Runner,
        // which never reaches wp_mail() when Do Not Send is on.
        if ( (bool) $options->get( 'general', 'do_not_send' ) ) {
            $log_data['status'] = 'blocked';
        }

        if ( null !== self::$resume_log_id ) {
            // The runner is re-sending a queued message; reuse the original
            // log row instead of inserting a fresh one.
            self::$current_log_id = (int) self::$resume_log_id;
            self::$resume_log_id  = null;

            if ( 'log' === $sandbox_decision ) {
                // Sandbox in log mode will short-circuit pre_wp_mail right after
                // this filter returns, so wp_mail_succeeded never fires and
                // mark_success never runs to terminate the row. Flip directly
                // to the terminal sandboxed/log state here.
                $log_data['status'] = 'sandboxed';
                Model::update_status( self::$current_log_id, 'sandboxed', null, null, null, 'log' );
            } else {
                // Redirect / bypassed / no sandbox decisions all fall through
                // to a real send whose mark_success / mark_failure will
                // terminate the row.
                $log_data['status'] = 'processing';
                Model::update_status( self::$current_log_id, 'processing' );
            }
        } else {
            self::$current_log_id = Model::insert( $log_data );
        }

        self::$current_source    = $source;
        self::$send_start_time   = microtime( true );
        self::$current_debug_log = '';

        return $args;
    }

    /**
     * Stash the queue row id that the next captured wp_mail() call belongs to.
     *
     * Queue\Manager calls this from the nimblymail_pre_send handler before
     * short-circuiting wp_mail(); capture_email() picks the value up and
     * persists it on the log row. The static is request-scoped and cleared
     * the moment capture_email reads it, so a subsequent unrelated send
     * cannot inherit a stale queue_id.
     *
     * @since 1.0.0
     *
     * @param int $queue_id The queue row id. Pass 0 to explicitly clear the static.
     *
     * @return void
     */
    public static function set_pending_queue_id( int $queue_id ): void {
        if ( $queue_id > 0 ) {
            self::$pending_queue_id = $queue_id;
            return;
        }

        // Explicit clear — used by Runner::process()'s finally block so a
        // hook that throws before capture_email() consumes the static does
        // not leak the pending queue id into the next Action Scheduler
        // batch item processed by the same PHP worker.
        self::$pending_queue_id = null;
    }

    /**
     * Pin the next captured wp_mail() call to an existing log row.
     *
     * Queue\Runner uses this immediately before re-invoking wp_mail() with a
     * bypass marker so the runner's send updates the original pending row
     * (set at enqueue time) rather than inserting a new one. capture_email()
     * consumes the value once and then clears it.
     *
     * @since 1.0.0
     *
     * @param int $log_id The pre-existing log row id. Pass 0 to explicitly clear the static.
     *
     * @return void
     */
    public static function set_resume_log_id( int $log_id ): void {
        if ( $log_id > 0 ) {
            self::$resume_log_id = $log_id;
            return;
        }

        // Explicit clear — see set_pending_queue_id() for the same rationale.
        self::$resume_log_id = null;
    }

    /**
     * Update the lifecycle status of an existing log row.
     *
     * Phase 3's QueueRunner uses this to flip a row through processing →
     * sent | failed without going through the wp_mail_succeeded hook (the
     * runner re-invokes wp_mail() with a bypass marker, which sets up its
     * own current_log_id; this method targets the original log row written
     * at enqueue time).
     *
     * @since 1.0.0
     *
     * @param int         $log_id      Existing log row id.
     * @param string      $status      One of pending | processing | sent | failed.
     * @param int|null    $duration_ms Optional duration in milliseconds.
     * @param string|null $error       Optional error message.
     *
     * @return bool True on success.
     */
    public static function update_lifecycle_status( int $log_id, string $status, ?int $duration_ms = null, ?string $error = null ): bool {
        if ( $log_id <= 0 ) {
            return false;
        }
        return Model::update_status( $log_id, $status, $error, $duration_ms );
    }

    /**
     * Mark an email as successfully sent.
     *
     * When the Developer Sandbox redirected this send, the row's status is
     * overridden to 'sandboxed' and the pre-rewrite recipients are merged
     * into the headers JSON so the Email Log detail modal can surface them.
     *
     * @since 1.0.0
     * @since 1.0.0 Override status to 'sandboxed' when sandbox redirect was active.
     *
     * @param array $mail_data wp_mail arguments passed to succeed action.
     *
     * @return void
     */
    public function mark_success( $mail_data ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
        if ( ! self::$current_log_id ) {
            return;
        }

        $duration_ms = self::$send_start_time
            ? (int) ( ( microtime( true ) - self::$send_start_time ) * 1000 )
            : null;

        $decision = SandboxInterceptor::consume_decision();

        if ( 'redirect' === $decision ) {
            Model::update_status( self::$current_log_id, 'sandboxed', '', $duration_ms, self::$current_debug_log );

            $originals = SandboxInterceptor::consume_redirect_originals();
            if ( null !== $originals ) {
                Model::update_sandbox_redirect(
                    (int) self::$current_log_id,
                    $originals['to'],
                    $originals['cc'],
                    $originals['bcc']
                );
            }
        } else {
            Model::update_status( self::$current_log_id, 'sent', '', $duration_ms, self::$current_debug_log );
        }

        self::$current_log_id    = null;
        self::$current_source    = '';
        self::$send_start_time   = null;
        self::$current_debug_log = '';
    }

    /**
     * Mark an email as failed.
     *
     * When the Developer Sandbox redirected this send and the redirected
     * delivery still failed (e.g. invalid safe_address), the row's status is
     * overridden to 'sandboxed' so the dashboard's failure stats are not
     * inflated by sandbox-induced misconfiguration.
     *
     * @since 1.0.0
     * @since 1.0.0 Override status to 'sandboxed' when sandbox redirect was active.
     *
     * @param WP_Error $error The error object.
     *
     * @return void
     */
    public function mark_failure( WP_Error $error ): void {
        if ( ! self::$current_log_id ) {
            return;
        }

        $duration_ms = self::$send_start_time
            ? (int) ( ( microtime( true ) - self::$send_start_time ) * 1000 )
            : null;

        $decision = SandboxInterceptor::consume_decision();

        if ( 'redirect' === $decision ) {
            // Even when the redirected delivery fails (e.g. invalid safe_address)
            // we record the row as 'sandboxed' so dashboard failure stats are not
            // inflated by sandbox-induced misconfiguration. The actual error
            // message is preserved.
            Model::update_status(
                self::$current_log_id,
                'sandboxed',
                $error->get_error_message(),
                $duration_ms,
                self::$current_debug_log
            );

            $originals = SandboxInterceptor::consume_redirect_originals();
            if ( null !== $originals ) {
                Model::update_sandbox_redirect(
                    (int) self::$current_log_id,
                    $originals['to'],
                    $originals['cc'],
                    $originals['bcc']
                );
            }
        } else {
            Model::update_status(
                self::$current_log_id,
                'failed',
                $error->get_error_message(),
                $duration_ms,
                self::$current_debug_log
            );
        }

        self::$current_log_id    = null;
        self::$current_source    = '';
        self::$send_start_time   = null;
        self::$current_debug_log = '';
    }

    /**
     * Return the log row ID for the in-flight send.
     *
     * Used by Processor::handle_success() to populate the nimblymail_mail_sent
     * payload without a second database query. Returns 0 when logging is
     * disabled or the row insertion failed.
     *
     * @since 1.0.0
     *
     * @return int
     */
    public static function get_current_log_id(): int {
        return self::$current_log_id ?? 0;
    }

    /**
     * Return the source identifier detected during the current send.
     *
     * Used by Processor::handle_success() to include the calling context in
     * the nimblymail_mail_sent payload without re-running the backtrace.
     *
     * @since 1.0.0
     *
     * @return string
     */
    public static function get_current_source(): string {
        return self::$current_source;
    }

    /**
     * Detect where the wp_mail call originated from via backtrace.
     *
     * @return string
     */
    private function detect_source(): string {
        $backtrace   = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
        $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR );
        $themes_dir  = wp_normalize_path( get_theme_root() );

        foreach ( $backtrace as $frame ) {
            if ( ! isset( $frame['file'] ) ) {
                continue;
            }

            $file = wp_normalize_path( $frame['file'] );

            // Check plugins directory.
            if ( strpos( $file, $plugins_dir . '/' ) === 0 ) {
                $relative = substr( $file, strlen( $plugins_dir ) + 1 );
                $parts    = explode( '/', $relative );
                $slug     = $parts[0] ?? 'unknown';
                // Don't attribute to ourselves.
                if ( 'nimblymail-smtp' !== $slug ) {
                    return $slug;
                }
            }

            // Check themes directory.
            if ( strpos( $file, $themes_dir . '/' ) === 0 ) {
                $relative = substr( $file, strlen( $themes_dir ) + 1 );
                $parts    = explode( '/', $relative );
                return 'theme:' . ( $parts[0] ?? 'unknown' );
            }
        }

        return 'WordPress';
    }

    /**
     * Parse headers for CC, BCC, and Content-Type.
     *
     * @param string|array $headers The raw headers.
     * @return array
     */
    private function parse_headers( $headers ): array {
        $result = array(
            'cc'           => '',
            'bcc'          => '',
            'content_type' => 'text/html',
        );

        if ( is_string( $headers ) ) {
            $headers = explode( "\n", str_replace( "\r\n", "\n", $headers ) );
        }

        if ( ! is_array( $headers ) ) {
            return $result;
        }

        foreach ( $headers as $header ) {
            if ( stripos( $header, 'cc:' ) === 0 ) {
                $result['cc'] = trim( substr( $header, 3 ) );
            } elseif ( stripos( $header, 'bcc:' ) === 0 ) {
                $result['bcc'] = trim( substr( $header, 4 ) );
            } elseif ( stripos( $header, 'content-type:' ) === 0 ) {
                $ct                     = trim( substr( $header, 13 ) );
                $result['content_type'] = strtok( $ct, ';' );
            }
        }

        return $result;
    }

    /**
     * Append string to the current debug log.
     *
     * @param string $log The debug string.
     */
    public static function append_debug_log( string $log ): void {
        if ( self::$current_log_id ) {
            self::$current_debug_log .= $log;
        }
    }
}
