<?php
/**
 * Abstract REST API Controller.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Rest;

use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Processor;
use NimblyMail\SMTP\SettingsProvider;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Class AbstractController
 *
 * Shared base for all REST controllers: REST namespace and permission check.
 *
 * @since 1.0.0
 * @since 1.0.3 Schema validation now also enforces scalar types, checkbox coercion, enum membership, and numeric ranges.
 * @since 1.0.5 Added validate_sandbox_settings(), hoisted from SettingsController so network-wide saves can enforce the same coherence check.
 */
abstract class AbstractController {

    /**
     * REST API namespace.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const NAMESPACE = 'nimblymail-smtp/v1';

    /**
     * Check if the current user can manage options.
     *
     * @since 1.0.0
     *
     * @return bool True if user has manage_options capability.
     */
    public function permissions_check(): bool {
        return current_user_can( 'manage_options' );
    }

    /**
     * Sentinel value used to mask encrypted fields in API responses.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const ENCRYPTED_MASK = '********';

    /**
     * Field types whose value must be a single scalar (string|int|float|bool),
     * never an array or object.
     *
     * Every field type in the schema that is actually saved to `Options`
     * stores a scalar — including `switch`/`segmented`/`mailer-cards`, which
     * render differently but still emit a single string/bool value. Types
     * left out (`button`, `link`, `table`, `code-block`, `date-range`,
     * `action-button`, `copyable-uri`) are either read-only/display-only or,
     * in the case of `date-range`, intentionally hold a non-scalar value, so
     * they are not subject to this check.
     *
     * @since 1.0.3
     *
     * @var string[]
     */
    private const SCALAR_FIELD_TYPES = array(
        'text',
        'email',
        'password',
        'number',
        'checkbox',
        'switch',
        'select',
        'radio',
        'segmented',
        'textarea',
        'mailer-cards',
    );

    /**
     * Field types that represent a boolean toggle and must be coerced to a
     * real PHP bool on save.
     *
     * @since 1.0.3
     *
     * @var string[]
     */
    private const CHECKBOX_FIELD_TYPES = array( 'checkbox', 'switch' );

    /**
     * Build a whitelist map of valid groups and field IDs from the settings schema.
     *
     * Returns an associative array: `{group => {fields, encrypted, types, enum, range}}`.
     * `types` maps every field id to its schema `type`. `enum` maps field ids
     * that declare an `options` array to the list of valid option keys.
     * `range` maps field ids that declare `min`/`max` to those bounds.
     *
     * @since 1.0.0
     * @since 1.0.3 Added `types`, `enum`, `range` so validate_against_schema()
     *              can enforce scalar types, enum membership, and numeric
     *              range without duplicating schema metadata.
     *
     * @param string $context Schema context — see SettingsProvider::get_tabs().
     *
     * @return array<string, array{fields: string[], encrypted: string[], types: array<string, string>, enum: array<string, string[]>, range: array<string, array{min?: int, max?: int}>}>
     */
    protected function build_field_whitelist( string $context = 'site' ): array {
        $tabs      = SettingsProvider::get_instance()->get_tabs( $context );
        $whitelist = array();

        foreach ( $tabs as $tab ) {
            if ( empty( $tab['sections'] ) ) {
                continue;
            }

            foreach ( $tab['sections'] as $section ) {
                if ( empty( $section['fields'] ) ) {
                    continue;
                }

                foreach ( $section['fields'] as $field ) {
                    $group = $field['group'] ?? '';
                    $id    = $field['id'] ?? '';

                    if ( '' === $group || '' === $id ) {
                        continue;
                    }

                    if ( ! isset( $whitelist[ $group ] ) ) {
                        $whitelist[ $group ] = array(
                            'fields'    => array(),
                            'encrypted' => array(),
                            'types'     => array(),
                            'enum'      => array(),
                            'range'     => array(),
                        );
                    }

                    $whitelist[ $group ]['fields'][]     = $id;
                    $whitelist[ $group ]['types'][ $id ] = (string) ( $field['type'] ?? '' );

                    if ( ! empty( $field['encrypted'] ) ) {
                        $whitelist[ $group ]['encrypted'][] = $id;
                    }

                    if ( ! empty( $field['options'] ) && is_array( $field['options'] ) ) {
                        $whitelist[ $group ]['enum'][ $id ] = array_keys( $field['options'] );
                    }

                    if ( isset( $field['min'] ) || isset( $field['max'] ) ) {
                        $bounds = array();
                        if ( isset( $field['min'] ) ) {
                            $bounds['min'] = (int) $field['min'];
                        }
                        if ( isset( $field['max'] ) ) {
                            $bounds['max'] = (int) $field['max'];
                        }
                        $whitelist[ $group ]['range'][ $id ] = $bounds;
                    }
                }
            }
        }

        return $whitelist;
    }

    /**
     * Validate incoming settings data against a schema whitelist.
     *
     * Rejects unknown groups/fields with 400. Silently drops constant-defined
     * fields. Skips masked encrypted fields. When $enforce_network_locks is
     * true, locked fields are collected and returned as a 403 with a
     * `locked_fields` error-data entry. Fields whose schema type is scalar
     * (see SCALAR_FIELD_TYPES) are rejected with a 400 if the incoming value
     * is an array/object; checkbox/switch fields are coerced to a real bool;
     * enum fields (schema `options`) are rejected with a 400 if the value is
     * not one of the declared option keys; number fields with a declared
     * `min`/`max` are clamped into range.
     *
     * @since 1.0.0
     * @since 1.0.3 Added scalar-type, checkbox-coercion, enum, and range
     *              enforcement so a payload can no longer reach Options::set()
     *              with a type/shape the schema doesn't allow.
     *
     * @param array<string, array<string, mixed>>                                                                                                                                         $data                  Settings data keyed by group.
     * @param array<string, array{fields: string[], encrypted: string[], types: array<string, string>, enum: array<string, string[]>, range: array<string, array{min?: int, max?: int}>}> $schema                Whitelist schema.
     * @param bool                                                                                                                                                                        $enforce_network_locks Reject writes to network-locked fields.
     *
     * @return array<string, array<string, mixed>>|WP_Error Validated data or error.
     */
    protected function validate_against_schema( array $data, array $schema, bool $enforce_network_locks = false ) {
        $validated     = array();
        $locked_fields = array();
        $options       = Options::init();

        foreach ( $data as $group => $fields ) {
            if ( ! is_array( $fields ) ) {
                continue;
            }

            if ( ! isset( $schema[ $group ] ) ) {
                return new WP_Error(
                    'rest_unknown_group',
                    sprintf(
                        /* translators: %s: the unknown settings group name. */
                        __( 'Unknown settings group: %s', 'nimblymail-smtp' ),
                        sanitize_text_field( (string) $group )
                    ),
                    array( 'status' => 400 )
                );
            }

            $valid_fields     = $schema[ $group ]['fields'];
            $encrypted_fields = $schema[ $group ]['encrypted'];
            $field_types      = $schema[ $group ]['types'] ?? array();
            $field_enums      = $schema[ $group ]['enum'] ?? array();
            $field_ranges     = $schema[ $group ]['range'] ?? array();

            foreach ( $fields as $field_id => $value ) {
                if ( ! in_array( $field_id, $valid_fields, true ) ) {
                    return new WP_Error(
                        'rest_unknown_field',
                        sprintf(
                            /* translators: %1$s: the field ID, %2$s: the settings group name. */
                            __( 'Unknown field "%1$s" in group "%2$s".', 'nimblymail-smtp' ),
                            sanitize_text_field( (string) $field_id ),
                            sanitize_text_field( (string) $group )
                        ),
                        array( 'status' => 400 )
                    );
                }

                if ( $options->is_const_defined( $group, $field_id ) ) {
                    continue;
                }

                if ( $enforce_network_locks && $options->is_network_locked( $group, $field_id ) ) {
                    $locked_fields[] = $group . '.' . $field_id;
                    continue;
                }

                if ( self::ENCRYPTED_MASK === $value && in_array( $field_id, $encrypted_fields, true ) ) {
                    continue;
                }

                $field_type = $field_types[ $field_id ] ?? '';

                if ( in_array( $field_type, self::SCALAR_FIELD_TYPES, true ) && ! is_scalar( $value ) ) {
                    return new WP_Error(
                        'rest_invalid_field_value',
                        sprintf(
                            /* translators: %1$s: the field ID, %2$s: the settings group name. */
                            __( 'Field "%1$s" in group "%2$s" must be a single value, not a list or object.', 'nimblymail-smtp' ),
                            sanitize_text_field( (string) $field_id ),
                            sanitize_text_field( (string) $group )
                        ),
                        array( 'status' => 400 )
                    );
                }

                if ( in_array( $field_type, self::CHECKBOX_FIELD_TYPES, true ) ) {
                    $value = self::coerce_checkbox_value( $value );
                }

                if ( isset( $field_enums[ $field_id ] ) && ! in_array( $value, $field_enums[ $field_id ], true ) ) {
                    return new WP_Error(
                        'rest_invalid_field_value',
                        sprintf(
                            /* translators: %1$s: the field ID, %2$s: the settings group name. */
                            __( 'Field "%1$s" in group "%2$s" is not one of the allowed values.', 'nimblymail-smtp' ),
                            sanitize_text_field( (string) $field_id ),
                            sanitize_text_field( (string) $group )
                        ),
                        array( 'status' => 400 )
                    );
                }

                if ( isset( $field_ranges[ $field_id ] ) ) {
                    $value = self::clamp_to_range( $value, $field_ranges[ $field_id ] );
                }

                if ( ! isset( $validated[ $group ] ) ) {
                    $validated[ $group ] = array();
                }

                $validated[ $group ][ $field_id ] = $value;
            }
        }

        if ( ! empty( $locked_fields ) ) {
            return new WP_Error(
                'rest_locked_fields',
                __( 'One or more fields are locked by the network administrator and cannot be changed on this site.', 'nimblymail-smtp' ),
                array(
                    'status'        => 403,
                    'locked_fields' => $locked_fields,
                )
            );
        }

        return $validated;
    }

    /**
     * Coerce an incoming checkbox/switch value to a real PHP bool.
     *
     * A native JSON boolean already arrives as a PHP bool and passes through
     * unchanged. A JSON string is only treated as "off" when it spells out a
     * falsy value ("false", "0", ""); every other string is "on" — this is
     * the exact case that let `{"smtp":{"auth":"false"}}` store a truthy
     * string and flip the setting the wrong way. Numeric values fall back to
     * PHP's normal zero-is-false rule.
     *
     * @since 1.0.3
     *
     * @param mixed $value Raw scalar value from the request (guaranteed
     *                      scalar — the caller runs the SCALAR_FIELD_TYPES
     *                      check first).
     *
     * @return bool
     */
    private static function coerce_checkbox_value( mixed $value ): bool {
        if ( is_bool( $value ) ) {
            return $value;
        }

        if ( is_string( $value ) ) {
            return ! in_array( strtolower( $value ), array( 'false', '0', '' ), true );
        }

        if ( is_numeric( $value ) ) {
            return 0.0 !== (float) $value;
        }

        return (bool) $value;
    }

    /**
     * Clamp a numeric field value into its schema-declared min/max range.
     *
     * Non-numeric values are returned unchanged — rejecting a malformed
     * number is a separate concern from range enforcement and isn't part of
     * the schema-declared bound being enforced here.
     *
     * @since 1.0.3
     *
     * @param mixed                       $value Raw value for the field.
     * @param array{min?: int, max?: int} $range Declared bounds, either or both present.
     *
     * @return mixed The clamped integer, or the original value when non-numeric.
     */
    private static function clamp_to_range( mixed $value, array $range ): mixed {
        if ( ! is_numeric( $value ) ) {
            return $value;
        }

        $number = (int) $value;

        if ( isset( $range['min'] ) && $number < $range['min'] ) {
            $number = $range['min'];
        }

        if ( isset( $range['max'] ) && $number > $range['max'] ) {
            $number = $range['max'];
        }

        return $number;
    }

    /**
     * Validate Developer Sandbox settings before persisting.
     *
     * The sandbox cannot be saved in an incoherent state. When the master
     * switch is on AND the mode is `redirect`, the safe address must be
     * provided and pass is_email(). The check operates on the merged view of
     * incoming POST data and the caller-supplied effective values so a user
     * toggling only one of the three fields still gets the validation applied
     * against the effective post-save state.
     *
     * Originally private on SettingsController and hardcoded to read
     * site-effective values straight from Options::init()->get(). Hoisted
     * here so NetworkSettingsController can enforce the same coherence check
     * on network-wide saves (issue #28), which the "effective" value means
     * something different for: a site save resolves the current value via
     * Options::get() (constant → site override → network default → plugin
     * default), while a network save resolves it via
     * Options::get_network_metadata() (constant → stored network value →
     * plugin default) — there is no "site override" layer to consult. Rather
     * than have this shared method special-case that resolution itself, each
     * caller resolves its own effective values and passes them in, keeping
     * this method a pure merge-and-check.
     *
     * @since 1.0.0
     * @since 1.0.5 Moved from SettingsController to AbstractController and
     *              changed to accept the effective values as a parameter
     *              instead of reading Options directly, so
     *              NetworkSettingsController can reuse it against the
     *              network-effective view.
     *
     * @param array $validated Validated POST data, keyed by settings group.
     * @param array $effective Caller-resolved effective values for
     *                          `enabled`, `mode`, and `safe_address`, used as
     *                          the base the incoming sandbox payload is
     *                          merged over.
     *
     * @return WP_Error|null WP_Error with status 400 when invalid, otherwise null.
     */
    protected function validate_sandbox_settings( array $validated, array $effective ): ?WP_Error {
        $incoming = $validated['sandbox'] ?? array();
        if ( empty( $incoming ) ) {
            return null;
        }

        $merged        = array_merge( $effective, $incoming );
        $merged_mode   = (string) $merged['mode'];
        $merged_safe   = (string) $merged['safe_address'];
        $merged_enable = (bool) $merged['enabled'];

        if ( $merged_enable && 'redirect' === $merged_mode && ( '' === $merged_safe || ! is_email( $merged_safe ) ) ) {
            return new WP_Error(
                'rest_invalid_sandbox_safe_address',
                __( 'A valid Safe Address is required when Sandbox is in Redirect mode.', 'nimblymail-smtp' ),
                array(
                    'status' => 400,
                    'field'  => 'sandbox.safe_address',
                )
            );
        }

        return null;
    }

    /**
     * Run a mail-sending callable while capturing PHPMailer SMTP debug output.
     *
     * Registers a short-lived `phpmailer_init` hook that enables SMTPDebug
     * level 2 and writes the conversation into a local buffer (capped at 64
     * kB to bound memory). The buffer is run through `Processor::sanitize_debug_log()`
     * before returning. Used by test-email endpoints.
     *
     * @since 1.0.0
     *
     * @param callable $sender Callable that performs the actual mail send and returns a boolean.
     *
     * @return array{success: bool, debug_log: string}
     */
    protected function send_with_debug_capture( callable $sender ): array {
        $debug_log = '';
        $max_len   = 64000;

        $hook = function ( $phpmailer ) use ( &$debug_log, $max_len ) {
            $phpmailer->SMTPDebug   = 2;
            $phpmailer->Debugoutput = function ( $str ) use ( &$debug_log, $max_len ) {
                if ( strlen( $debug_log ) < $max_len ) {
                    $debug_log .= $str;
                }
            };
        };

        add_action( 'phpmailer_init', $hook, 1000 );

        try {
            $success = (bool) $sender();
        } finally {
            remove_action( 'phpmailer_init', $hook, 1000 );
        }

        return array(
            'success'   => $success,
            'debug_log' => Processor::sanitize_debug_log( $debug_log ),
        );
    }

    /**
     * Storage prefix for rate-limit transients written by check_rate_limit().
     *
     * Centralised so every transient key written by the rate limiter is
     * verifiably prefixed with `nimblymail_` regardless of what the
     * caller passes — it gets prepended at the storage call site below.
     *
     * @since 1.0.0
     *
     * @var string
     */
    private const RATE_LIMIT_TRANSIENT_PREFIX = 'nimblymail_';

    /**
     * Check and enforce a transient-based rate limit for the current user.
     *
     * Increments a per-user counter transient on each call. If the counter
     * exceeds the limit, returns a WP_Error with HTTP 429 status and
     * Retry-After header. The stored transient key is built as
     * `nimblymail_<sub_key>_<user_id>` so the plugin's data-storage
     * prefix is visible at every call site.
     *
     * @since 1.0.0
     *
     * @param string $sub_key        Caller-specific sub-key (must already be unique
     *                               within the plugin; the function prefixes it
     *                               with `nimblymail_` and suffixes the user ID).
     * @param int    $max_requests   Maximum allowed requests within the window.
     * @param int    $window_seconds TTL for the rate limit window in seconds.
     *
     * @return WP_Error|null WP_Error on rate limit exceeded, null otherwise.
     */
    protected function check_rate_limit( string $sub_key, int $max_requests, int $window_seconds ): ?WP_Error {
        $user_id                = get_current_user_id();
        $nimblymail_storage_key = self::RATE_LIMIT_TRANSIENT_PREFIX . ltrim( $sub_key, '_' ) . '_' . $user_id;
        $count                  = (int) get_transient( $nimblymail_storage_key );

        if ( $count >= $max_requests ) {
            add_filter(
                'rest_post_dispatch',
                function ( $response ) use ( $window_seconds ) {
                    $response->header( 'Retry-After', $window_seconds );
                    return $response;
                }
            );

            return new WP_Error(
                'rate_limit_exceeded',
                sprintf(
                    /* translators: %d: maximum number of requests allowed. */
                    __( 'Rate limit exceeded. Maximum %d requests per minute.', 'nimblymail-smtp' ),
                    $max_requests
                ),
                array( 'status' => 429 )
            );
        }

        set_transient( $nimblymail_storage_key, $count + 1, $window_seconds );

        return null;
    }
}
