<?php
/**
 * Abstract Importer base class.
 *
 * @since 1.0.0
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Import;

use NimblyMail\SMTP\Log\Rotation;
use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Rest\AbstractController;

defined( 'ABSPATH' ) || exit;

/**
 * Class AbstractImporter
 *
 * Base class for settings importers from other SMTP plugins.
 * Each concrete importer reads settings from a specific source plugin
 * and maps them to the NimblyMail options format.
 *
 * @since 1.0.0
 * @since 1.0.4 An imported log-rotation preference is now marked as
 *              deliberately configured, so it is treated the same as one saved
 *              by hand. Dropped the mapping that pointed Zoho Mail at Zoho's
 *              unrelated transactional service. Added a shared safety net so a
 *              mailer is only ever selected when its credentials actually came
 *              across, and a shared skip helper so a source mailer NimblyMail
 *              cannot handle is reported to the user rather than passed over
 *              in silence.
 * @since 1.0.5 preview() now masks sensitive credential values before
 *              returning them, so the settings-import preview never exposes
 *              cleartext credentials. read_source_option()'s main-site
 *              fallback (multisite sub-sites only) now requires the caller
 *              to hold manage_network_options, so an ordinary sub-site admin
 *              can no longer read the main site's stored credentials.
 * @since 1.0.6 Added 'outlook' to SUPPORTED_MAILERS, so a source plugin's
 *              Outlook / Microsoft 365 mailer (all three supported source
 *              plugins use the literal slug 'outlook') can now be imported.
 */
abstract class AbstractImporter {

    /**
     * NimblyMail mailer slugs that are supported for import mapping.
     *
     * @since 1.0.0
     * @since 1.0.6 Added 'outlook'.
     *
     * @var array<string>
     */
    protected const SUPPORTED_MAILERS = array( 'php', 'smtp', 'gmail', 'outlook', 'ses', 'resend', 'sendlayer', 'brevo', 'zeptomail' );

    /**
     * Get the importer slug identifier.
     *
     * @since 1.0.0
     *
     * @return string
     */
    abstract public function get_slug(): string;

    /**
     * Get the human-readable name of the source plugin.
     *
     * @since 1.0.0
     *
     * @return string
     */
    abstract public function get_name(): string;

    /**
     * Get a short description for the importer card UI.
     *
     * @since 1.0.0
     *
     * @return string
     */
    abstract public function get_description(): string;

    /**
     * Get the wp_options key for the source plugin settings.
     *
     * @since 1.0.0
     *
     * @return string
     */
    abstract public function get_option_key(): string;

    /**
     * Get the plugin basename for the source plugin.
     *
     * Used for active-plugin detection via is_plugin_active().
     * Example: 'easy-wp-smtp/easy-wp-smtp.php'.
     *
     * @since 1.0.0
     *
     * @return string
     */
    abstract public function get_plugin_basename(): string;

    /**
     * Map source plugin settings to NimblyMail format.
     *
     * Returns an associative array with 'settings', 'warnings', and 'skipped' keys.
     *
     * @since 1.0.0
     *
     * @param array $source_data Raw source plugin options array.
     *
     * @return array{settings: array, warnings: array, skipped: array}
     */
    abstract protected function map_settings( array $source_data ): array;

    /**
     * Check if any of the supplied plugin basenames is currently active.
     *
     * Considers a plugin active when it is enabled on the current site OR
     * network-activated on multisite. Subclasses with multiple basenames
     * (free + Pro) should pass all of them.
     *
     * @since 1.0.0
     *
     * @param array<int, string> $basenames Plugin basenames to check (e.g. 'easy-wp-smtp/easy-wp-smtp.php').
     *
     * @return bool True when any basename is active in any scope.
     */
    protected function is_source_plugin_active( array $basenames ): bool {
        if ( ! function_exists( 'is_plugin_active' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        $is_multisite = is_multisite();

        foreach ( $basenames as $basename ) {
            if ( is_plugin_active( $basename ) ) {
                return true;
            }

            if ( $is_multisite && is_plugin_active_for_network( $basename ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Read a source plugin option, falling back across multisite scopes.
     *
     * Lookup order: current-site `get_option`, then on multisite the network
     * `get_site_option`, then the main site's per-site option (when called
     * from a sub-site). Always returns an array — non-array stored values
     * are coerced to an empty array.
     *
     * The main-site-per-site-option fallback (the third tier) is only
     * genuinely network-scoped in the loose sense that it happens to hold
     * network-wide credentials in practice (e.g. a network-activated source
     * plugin) — it is still just that one site's own per-site option, not
     * data `manage_options` on a sub-site has any legitimate access to.
     * Restricting it to `manage_network_options` treats sub-site admins as a
     * lower trust tier for it, matching how this plugin already treats them
     * everywhere else (network field locks, strip_network_locked_fields()).
     * The `get_site_option` tier above is untouched — it is genuinely
     * network-scoped storage and stays available to any site admin.
     *
     * @since 1.0.0
     * @since 1.0.5 The main-site fallback now also requires the current user
     *              to hold manage_network_options, so an ordinary sub-site
     *              admin can no longer read the main site's stored
     *              credentials via a network-activated source plugin.
     *
     * @param string $option_key The option key to read.
     *
     * @return array The stored option value, or an empty array when not found.
     */
    protected function read_source_option( string $option_key ): array {
        $data = get_option( $option_key, false );

        if ( false !== $data ) {
            return is_array( $data ) ? $data : array();
        }

        if ( ! is_multisite() ) {
            return array();
        }

        $data = get_site_option( $option_key, false );

        if ( false !== $data ) {
            return is_array( $data ) ? $data : array();
        }

        $main_site_id = (int) get_main_site_id();

        if ( get_current_blog_id() !== $main_site_id && current_user_can( 'manage_network_options' ) ) {
            switch_to_blog( $main_site_id );
            $data = get_option( $option_key, array() );
            restore_current_blog();
            return is_array( $data ) ? $data : array();
        }

        return array();
    }

    /**
     * Check if the source plugin is active and has importable data.
     *
     * Requires the source plugin to be active (site or network) AND for its
     * settings option to be readable in some scope (current site, network,
     * or main site on multisite). Subclasses that detect multiple basenames
     * or fall back across multiple option keys should override this and call
     * `is_source_plugin_active()` / `get_source_data()` with the relevant
     * inputs.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public function is_available(): bool {
        if ( ! $this->is_source_plugin_active( array( $this->get_plugin_basename() ) ) ) {
            return false;
        }

        return ! empty( $this->get_source_data() );
    }

    /**
     * Get the raw source plugin settings from the database.
     *
     * Delegates to `read_source_option()` so subclasses inherit the
     * network/main-site fallback behavior automatically.
     *
     * @since 1.0.0
     *
     * @return array
     */
    public function get_source_data(): array {
        return $this->read_source_option( $this->get_option_key() );
    }

    /**
     * Preview the import without saving.
     *
     * Returns the mapped settings, warnings, and skipped fields
     * so the user can review before confirming.
     *
     * @since 1.0.0
     * @since 1.0.5 Sensitive credential values in the returned `settings` are
     *              now masked. This is a review-only surface, so masking it
     *              at the source protects every current and future consumer
     *              of preview() rather than relying on each caller (e.g. the
     *              REST controller) to remember to mask it itself.
     *              execute_import() is unaffected — it calls map_settings()
     *              independently and never shares this masked array.
     *
     * @return array{settings: array, warnings: array, skipped: array}
     */
    public function preview(): array {
        $source_data = $this->get_source_data();

        if ( empty( $source_data ) ) {
            return array(
                'settings' => array(),
                'warnings' => array(),
                'skipped'  => array(),
            );
        }

        $mapped             = $this->map_settings( $source_data );
        $mapped['settings'] = $this->mask_sensitive_settings( $mapped['settings'] );

        return $mapped;
    }

    /**
     * Mask sensitive credential values in a mapped settings array.
     *
     * Mirrors SettingsProvider::normalize_field()'s "do not send actual
     * encrypted values to JS" behavior for the settings-import preview: the
     * import UI already masks these fields client-side with placeholder
     * text, so returning the plaintext value buys nothing functionally and
     * only puts live credentials into browser memory, devtools, or proxy
     * captures during the import flow. Empty values are left untouched —
     * masking an empty value would misleadingly suggest a credential exists
     * when it doesn't.
     *
     * @since 1.0.5
     *
     * @param array $settings Mapped settings, shaped as $settings[group][key] = value.
     *
     * @return array The settings array with sensitive values masked.
     */
    private function mask_sensitive_settings( array $settings ): array {
        foreach ( $settings as $group => $fields ) {
            if ( ! is_array( $fields ) ) {
                continue;
            }

            foreach ( $fields as $key => $value ) {
                if ( Options::is_sensitive_field( (string) $group, (string) $key ) && ! empty( $value ) ) {
                    $settings[ $group ][ $key ] = AbstractController::ENCRYPTED_MASK;
                }
            }
        }

        return $settings;
    }

    /**
     * Execute the import and save settings to NimblyMail.
     *
     * Maps the source settings and saves them via Options::init()->set().
     * Returns the result including any warnings about skipped credentials.
     *
     * @since 1.0.0
     * @since 1.0.4 Records the rotation-configured marker when the imported
     *              settings carry a rotation preference, so an explicit
     *              retention period brought over from another plugin is
     *              respected instead of being overridden by the default
     *              retention floor. Known narrow gap: on multisite, if a
     *              super admin has network-locked the rotation group with no
     *              network value set, Options::set() silently strips it
     *              before persisting but the marker is still recorded from
     *              the pre-strip payload — the same class of issue fixed for
     *              the settings REST endpoint, just unreachable there
     *              because that endpoint validates against locks first.
     *
     * @return array{success: bool, message: string, warnings: array}
     */
    public function execute_import(): array {
        $source_data = $this->get_source_data();

        if ( empty( $source_data ) ) {
            return array(
                'success'  => false,
                'message'  => sprintf(
                    /* translators: %s: source plugin name. */
                    __( 'No settings found for %s.', 'nimblymail-smtp' ),
                    $this->get_name()
                ),
                'warnings' => array(),
            );
        }

        $mapped = $this->map_settings( $source_data );

        if ( empty( $mapped['settings'] ) ) {
            return array(
                'success'  => false,
                'message'  => sprintf(
                    /* translators: %s: source plugin name. */
                    __( 'No compatible settings found in %s to import.', 'nimblymail-smtp' ),
                    $this->get_name()
                ),
                'warnings' => $mapped['warnings'],
            );
        }

        Options::init()->set( $mapped['settings'] );
        Rotation::maybe_mark_configured( $mapped['settings'] );

        return array(
            'success'  => true,
            'message'  => sprintf(
                /* translators: %s: source plugin name. */
                __( 'Settings imported successfully from %s.', 'nimblymail-smtp' ),
                $this->get_name()
            ),
            'warnings' => $mapped['warnings'],
        );
    }

    /**
     * Map a source mailer slug to a NimblyMail mailer slug.
     *
     * @since 1.0.0
     * @since 1.0.4 Zoho Mail no longer resolves to Zoho's separate
     *              transactional service. They are different products with
     *              incompatible credentials, so the pairing was wrong and a
     *              Zoho Mail source is now correctly treated as unsupported.
     *
     * @param string $source_mailer The source plugin's mailer identifier.
     *
     * @return string|null The NimblyMail mailer slug, or null if unsupported.
     */
    protected function map_mailer_slug( string $source_mailer ): ?string {
        $mapping = array(
            'mail'       => 'php',
            'default'    => 'php',
            'other_smtp' => 'smtp',
            'smtp'       => 'smtp',
            'gmail'      => 'gmail',
            'ses'        => 'ses',
            'amazonses'  => 'ses',
            'resend'     => 'resend',
            'sendlayer'  => 'sendlayer',
            'brevo'      => 'brevo',
            'sendinblue' => 'brevo',
            'zeptomail'  => 'zeptomail',
        );

        if ( isset( $mapping[ $source_mailer ] ) ) {
            return $mapping[ $source_mailer ];
        }

        if ( in_array( $source_mailer, self::SUPPORTED_MAILERS, true ) ) {
            return $source_mailer;
        }

        return null;
    }

    /**
     * Convert a yes/no string to a boolean.
     *
     * @since 1.0.0
     *
     * @param mixed $value The value to convert.
     *
     * @return bool
     */
    protected function to_bool( $value ): bool {
        if ( is_bool( $value ) ) {
            return $value;
        }

        if ( is_string( $value ) ) {
            return in_array( strtolower( $value ), array( 'yes', '1', 'true', 'on' ), true );
        }

        return (bool) $value;
    }

    /**
     * Add a credential warning to the warnings array.
     *
     * @since 1.0.0
     *
     * @param string $field_label Human-readable field label.
     * @param string $field_key   The NimblyMail settings key (e.g. 'smtp.pass').
     * @param array  $warnings    Warnings array passed by reference.
     */
    protected function add_credential_warning( string $field_label, string $field_key, array &$warnings ): void {
        $warnings[] = array(
            'field'   => $field_key,
            'message' => sprintf(
                /* translators: %s: credential field label. */
                __( '%s could not be imported because it is encrypted by the source plugin. Please re-enter it manually in NimblyMail settings.', 'nimblymail-smtp' ),
                $field_label
            ),
        );
    }

    /**
     * Add a skipped field entry.
     *
     * @since 1.0.0
     *
     * @param string $field_key The NimblyMail settings key.
     * @param string $reason    Reason the field was skipped.
     * @param array  $skipped   Skipped array passed by reference.
     */
    protected function add_skipped( string $field_key, string $reason, array &$skipped ): void {
        $skipped[] = array(
            'field'  => $field_key,
            'reason' => $reason,
        );
    }

    /**
     * Skip an encrypted credential field with both a warning and a skipped entry.
     *
     * Convenience method that combines add_credential_warning() and add_skipped()
     * for the common case of an encrypted field that cannot be imported.
     *
     * @since 1.0.0
     *
     * @param string $field_label Human-readable field label.
     * @param string $field_key   The NimblyMail settings key (e.g. 'smtp.pass').
     * @param array  $warnings    Warnings array passed by reference.
     * @param array  $skipped     Skipped array passed by reference.
     */
    protected function skip_encrypted( string $field_label, string $field_key, array &$warnings, array &$skipped ): void {
        $this->add_credential_warning( $field_label, $field_key, $warnings );
        $this->add_skipped( $field_key, __( 'Encrypted by source plugin', 'nimblymail-smtp' ), $skipped );
    }

    /**
     * Skip a credential that lives in a wp-config.php constant.
     *
     * Used by source plugins (e.g. Fluent SMTP with `key_store == 'wp_config'`)
     * that read credentials from PHP constants rather than the database. The
     * value is not in the option payload, so it cannot be imported.
     *
     * @since 1.0.0
     *
     * @param string $field_label    Human-readable field label.
     * @param string $field_key      The NimblyMail settings key (e.g. 'smtp.pass').
     * @param string $target_constant The NimblyMail constant the user should define instead.
     * @param array  $warnings       Warnings array passed by reference.
     * @param array  $skipped        Skipped array passed by reference.
     */
    protected function skip_wpconfig( string $field_label, string $field_key, string $target_constant, array &$warnings, array &$skipped ): void {
        $warnings[] = array(
            'field'   => $field_key,
            'message' => sprintf(
                /* translators: 1: credential field label, 2: NimblyMail constant name. */
                __( '%1$s is stored in a wp-config.php constant by the source plugin and could not be imported. Please define %2$s in your wp-config.php for NimblyMail.', 'nimblymail-smtp' ),
                $field_label,
                $target_constant
            ),
        );
        $this->add_skipped( $field_key, __( 'Defined in wp-config.php constant', 'nimblymail-smtp' ), $skipped );
    }

    /**
     * Skip a source mailer that NimblyMail has no counterpart for.
     *
     * Records a warning as well as a skipped entry. Skipped entries are not
     * shown anywhere in the interface and are dropped from the response the
     * import screen receives, so recording only a skip would leave the user
     * with no indication that no mailer was selected and that their site is
     * still sending through whatever it used before — the silent fallback
     * issue #25 describes.
     *
     * @since 1.0.4
     *
     * @param string $source_mailer The source plugin's mailer identifier.
     * @param array  $warnings      Warnings array passed by reference.
     * @param array  $skipped       Skipped array passed by reference.
     *
     * @return void
     */
    protected function skip_unsupported_mailer( string $source_mailer, array &$warnings, array &$skipped ): void {
        $warnings[] = array(
            'field'   => 'mail.mailer',
            'message' => sprintf(
                /* translators: %s: mailer slug from the source plugin. */
                __( 'The "%s" mailer is not supported by NimblyMail, so no mailer was selected during import. Your existing NimblyMail mailer is unchanged — please choose and configure one manually in NimblyMail settings.', 'nimblymail-smtp' ),
                $source_mailer
            ),
        );
        $this->add_skipped(
            'mail.mailer',
            sprintf(
                /* translators: %s: mailer slug from the source plugin. */
                __( 'Mailer "%s" is not supported by NimblyMail and was skipped.', 'nimblymail-smtp' ),
                $source_mailer
            ),
            $skipped
        );
    }

    /**
     * Only allow mail.mailer to point at a mailer whose credential group was
     * actually populated by the import.
     *
     * A resolved mailer slug (e.g. 'ses') can still end up with an empty
     * settings group when every credential field for it was skipped
     * (encrypted, wp-config-only, or simply absent from the source data).
     * Without this guard, mail.mailer would be saved pointing at a mailer
     * with no usable credentials, which silently falls back to PHP mail()
     * at send time — the exact "worse than not importing at all" failure
     * mode described in issue #25. Concrete importers must call this at the
     * end of their own map_settings(), after every provider-specific branch
     * has run, passing the mailer slug they resolved.
     *
     * @since 1.0.4
     *
     * @param string|null $mailer_slug The resolved NimblyMail mailer slug, or null if unsupported.
     * @param array       $settings    Settings array passed by reference.
     * @param array       $warnings    Warnings array passed by reference.
     * @param array       $skipped     Skipped array passed by reference.
     *
     * @return void
     */
    protected function guard_mailer_has_credentials( ?string $mailer_slug, array &$settings, array &$warnings, array &$skipped ): void {
        // Every mailer stores its credentials in a settings group named after
        // the mailer itself. 'php' is the one mailer that needs no credentials.
        if ( null === $mailer_slug || 'php' === $mailer_slug ) {
            return;
        }

        if ( ! empty( $settings[ $mailer_slug ] ) ) {
            return;
        }

        // A skip_wpconfig()/skip_encrypted() warning already explained the
        // gap for this group (e.g. 'smtp.pass') — don't pile on a second,
        // redundant warning for the same underlying cause.
        foreach ( $warnings as $warning ) {
            if ( isset( $warning['field'] ) && 0 === strpos( (string) $warning['field'], $mailer_slug . '.' ) ) {
                return;
            }
        }

        unset( $settings['mail']['mailer'] );

        $warnings[] = array(
            'field'   => 'mail.mailer',
            'message' => sprintf(
                /* translators: %s: NimblyMail mailer slug (e.g. ses, brevo). */
                __( 'No usable credentials were found for the "%s" mailer, so it was not selected during import. Please configure it manually in NimblyMail settings.', 'nimblymail-smtp' ),
                $mailer_slug
            ),
        );
        $this->add_skipped( 'mail.mailer', __( 'No credentials available for the selected mailer', 'nimblymail-smtp' ), $skipped );
    }
}
