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

namespace NimblyMail\SMTP\Rest;

use NimblyMail\SMTP\Log\Model;
use NimblyMail\SMTP\Options;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class DashboardController
 *
 * Exposes the dashboard data endpoints (stats, config, widgets) and the new
 * KPI extension surface introduced in 1.0.0. Pro add-ons hook
 * `nimblymail_dashboard_kpis` to inject extra KPI cards into the dashboard's
 * primary metric row, while `nimblymail_dashboard_widgets` continues to
 * cover non-KPI extensions (charts, tables, custom Vue renderers).
 *
 * @since 1.0.0 Added /dashboard/kpis route, ?compare on /dashboard/stats, and the default KPI registration.
 */
class DashboardController extends AbstractController {

    /**
     * Map of supported period slugs to lookback days.
     *
     * @since 1.0.0
     *
     * @var array<string, int>
     */
    private const PERIOD_DAYS = array(
        '24h' => 1,
        '7d'  => 7,
        '30d' => 30,
        'all' => 0,
    );

    /**
     * Constructor — wires the default KPI registration.
     *
     * The controller registers itself as the priority-10 listener on
     * `nimblymail_dashboard_kpis`, providing the four free defaults
     * (`total`, `sent`, `failed`, `success_rate`). Pro add-ons hook the same
     * filter at any priority and either prepend, append, or replace KPIs.
     *
     * @since 1.0.0
     */
    public function __construct() {
        add_filter( 'nimblymail_dashboard_kpis', array( $this, 'register_default_kpis' ), 10, 1 );
    }

    /**
     * Register the REST routes.
     *
     * @since 1.0.0 Added /dashboard/kpis. Added optional compare query arg on /dashboard/stats.
     */
    public function register_routes(): void {
        register_rest_route(
            self::NAMESPACE,
            '/dashboard/stats',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_stats' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'period'  => array(
                            'type'              => 'string',
                            'default'           => '7d',
                            'enum'              => array_keys( self::PERIOD_DAYS ),
                            'sanitize_callback' => 'sanitize_text_field',
                        ),
                        'compare' => array(
                            'type'              => 'boolean',
                            'default'           => false,
                            'sanitize_callback' => 'rest_sanitize_boolean',
                        ),
                    ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/dashboard/config',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_config' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/dashboard/widgets',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_widgets' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/dashboard/kpis',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_kpis' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                ),
            )
        );
    }

    /**
     * Get dashboard configuration summary.
     *
     * Returns a compact subset of option values needed by the dashboard widget:
     * the active mailer and whether email logging is enabled.
     *
     * @since 1.0.0
     *
     * @return WP_REST_Response
     */
    public function get_config(): WP_REST_Response {
        $options = Options::init();

        return new WP_REST_Response(
            array(
                'mail' => array(
                    'mailer' => $options->get( 'mail', 'mailer' ),
                ),
                'logs' => array(
                    'enabled' => (bool) $options->get( 'logs', 'enabled' ),
                ),
            )
        );
    }

    /**
     * Get extension widgets for the dashboard.
     *
     * Returns widget descriptors registered by Pro add-ons via the
     * nimblymail_dashboard_widgets filter. The default is an empty array —
     * the existing hard-coded dashboard sections are not included here.
     *
     * Each descriptor shape:
     *   id               string  — unique per page.
     *   type             string  — stat-card|table|activity-feed|alert-banner|status-list|chart|custom.
     *   label            string  — display label.
     *   icon             string  — optional Ant icon name.
     *   position         string  — sidebar|after-stats|after-activity|before-stats (default after-activity).
     *   data             mixed   — optional inline data payload.
     *   data_endpoint    string  — optional REST URL; browser fetches on mount.
     *   refresh_interval int     — seconds; browser re-fetches while tab is visible.
     *   renderer         string  — for type=custom, slug looked up in customRenderers.ts.
     *
     * @since 1.0.0 Added the `sidebar` position to the documented vocabulary.
     *
     * @return WP_REST_Response
     */
    public function get_widgets(): WP_REST_Response {
        /**
         * Filters the list of dashboard widgets rendered under the built-in sections.
         *
         * Pro add-ons hook this filter to inject extra widgets (stat cards, tables,
         * charts, etc.) without shipping Vue code for common widget types.
         *
         * @since 1.0.0
         *
         * @param array               $widgets    Numerically-indexed list of widget descriptors.
         * @param DashboardController $controller The controller instance, for reuse of helpers.
         */
        $widgets = apply_filters( 'nimblymail_dashboard_widgets', array(), $this );
        return new WP_REST_Response( array( 'widgets' => array_values( (array) $widgets ) ) );
    }

    /**
     * Get the registered KPI descriptors for the dashboard primary metric row.
     *
     * Runs the `nimblymail_dashboard_kpis` filter, sanitises every descriptor,
     * sorts the result by `priority` (ascending — lower numbers render first),
     * and returns the list. Each descriptor only describes how to render and
     * fetch a KPI; the actual values are pulled by the browser from each KPI's
     * `data_endpoint` (see Frontend KPI store).
     *
     * Free plugin defaults (priority 10–40):
     *   total, sent, failed, success_rate.
     *
     * Pro add-ons may register additional KPIs at any priority. Conventional
     * priority bands:
     *   10 – 50  Free plugin metrics.
     *   100 +    Pro / third-party metrics (default `priority` is 100).
     *
     * @since 1.0.0
     *
     * @return WP_REST_Response
     */
    public function get_kpis(): WP_REST_Response {
        /**
         * Filters the list of KPI descriptors rendered in the dashboard primary metric row.
         *
         * Each descriptor is an associative array with the following keys:
         *
         *   id            string  Unique slug, e.g. 'total' or 'pro-backup-retries'.
         *   label         string  Translated label rendered above the value.
         *   data_endpoint string  Relative REST path under nimblymail-smtp/v1/, e.g.
         *                         '/nimblymail-smtp/v1/dashboard/stats'. Absolute URLs
         *                         are rejected client-side.
         *   value_field   string  Dot-notation key into the response payload. The
         *                         browser walks this path to pull the rendered value
         *                         (e.g. 'total' or 'metrics.retries').
         *   delta_field   string|null
         *                         Optional dot-notation key for the prior-period value
         *                         used to compute the delta. When null the KPI never
         *                         renders a delta.
         *   suffix        string|null
         *                         Optional unit suffix appended after the formatted
         *                         value, e.g. '%' or ' ms'.
         *   invert_color  bool    When true, lower values are coloured green and higher
         *                         values are coloured red. Use for failure counts and
         *                         latency. Default false.
         *   priority      int     Sort order. Lower renders first. Default 100.
         *   formatter     string|null
         *                         Optional formatter slug applied client-side. One of
         *                         'integer', 'percent', 'duration_ms', 'plain'. When null
         *                         the value is rendered with `toLocaleString()`.
         *
         * Descriptors are sanitised after this filter runs: unknown keys are
         * stripped, REST paths must start with the plugin namespace, and id is
         * coerced to a slug. Invalid descriptors are dropped.
         *
         * @since 1.0.0
         *
         * @param array               $kpis       Numerically-indexed list of KPI descriptors.
         * @param DashboardController $controller The controller instance, for reuse of helpers.
         */
        $kpis = apply_filters( 'nimblymail_dashboard_kpis', array(), $this );

        $sanitised = array();
        foreach ( (array) $kpis as $kpi ) {
            $clean = $this->sanitize_kpi_descriptor( $kpi );
            if ( null !== $clean ) {
                $sanitised[] = $clean;
            }
        }

        usort(
            $sanitised,
            static function ( array $a, array $b ): int {
                return $a['priority'] <=> $b['priority'];
            }
        );

        return new WP_REST_Response( array( 'kpis' => $sanitised ) );
    }

    /**
     * Provide the four default free-plugin KPI descriptors.
     *
     * Hooked at priority 10 from the constructor so Pro add-ons that hook at
     * priority 11+ see the defaults already in the array and can decide
     * whether to append, replace, or remove them.
     *
     * @since 1.0.0
     *
     * @param array $kpis Existing KPI descriptors.
     *
     * @return array
     */
    public function register_default_kpis( array $kpis ): array {
        $defaults = array(
            array(
                'id'            => 'total',
                'label'         => __( 'Total', 'nimblymail-smtp' ),
                'data_endpoint' => '/nimblymail-smtp/v1/dashboard/stats',
                'value_field'   => 'total',
                'delta_field'   => 'previous.total',
                'suffix'        => null,
                'invert_color'  => false,
                'priority'      => 10,
                'formatter'     => 'integer',
            ),
            array(
                'id'            => 'sent',
                'label'         => __( 'Sent', 'nimblymail-smtp' ),
                'data_endpoint' => '/nimblymail-smtp/v1/dashboard/stats',
                'value_field'   => 'sent',
                'delta_field'   => 'previous.sent',
                'suffix'        => null,
                'invert_color'  => false,
                'priority'      => 20,
                'formatter'     => 'integer',
            ),
            array(
                'id'            => 'failed',
                'label'         => __( 'Failed', 'nimblymail-smtp' ),
                'data_endpoint' => '/nimblymail-smtp/v1/dashboard/stats',
                'value_field'   => 'failed',
                'delta_field'   => 'previous.failed',
                'suffix'        => null,
                'invert_color'  => true,
                'priority'      => 30,
                'formatter'     => 'integer',
            ),
            array(
                'id'            => 'success_rate',
                'label'         => __( 'Success', 'nimblymail-smtp' ),
                'data_endpoint' => '/nimblymail-smtp/v1/dashboard/stats',
                'value_field'   => 'success_rate',
                'delta_field'   => 'previous.success_rate',
                'suffix'        => '%',
                'invert_color'  => false,
                'priority'      => 40,
                'formatter'     => 'percent',
            ),
            array(
                'id'            => 'queue',
                'label'         => __( 'Queue', 'nimblymail-smtp' ),
                'data_endpoint' => '/nimblymail-smtp/v1/queue/stats',
                'value_field'   => 'pending',
                'delta_field'   => null,
                'suffix'        => null,
                'invert_color'  => true,
                'priority'      => 50,
                'formatter'     => 'integer',
            ),
        );

        return array_merge( $kpis, $defaults );
    }

    /**
     * Sanitise a single KPI descriptor coming through the filter.
     *
     * Strips unknown keys, coerces types, validates the REST path against the
     * plugin namespace and the formatter against the allowed list. Returns
     * null when the descriptor is missing required keys or fails validation.
     *
     * @since 1.0.0
     * @since 1.0.2 Simplified the optional-key handling; the existence checks already rule out null values.
     *
     * @param mixed $kpi Raw filter value (expected: associative array).
     *
     * @return array<string, mixed>|null
     */
    private function sanitize_kpi_descriptor( $kpi ): ?array {
        if ( ! is_array( $kpi ) ) {
            return null;
        }

        $id            = isset( $kpi['id'] ) ? sanitize_key( (string) $kpi['id'] ) : '';
        $label         = isset( $kpi['label'] ) ? sanitize_text_field( (string) $kpi['label'] ) : '';
        $data_endpoint = isset( $kpi['data_endpoint'] ) ? (string) $kpi['data_endpoint'] : '';
        $value_field   = isset( $kpi['value_field'] ) ? (string) $kpi['value_field'] : '';

        if ( '' === $id || '' === $label || '' === $data_endpoint || '' === $value_field ) {
            return null;
        }

        if ( ! $this->is_valid_rest_path( $data_endpoint ) ) {
            return null;
        }

        $delta_field = isset( $kpi['delta_field'] )
            ? (string) $kpi['delta_field']
            : null;

        $suffix = isset( $kpi['suffix'] )
            ? sanitize_text_field( (string) $kpi['suffix'] )
            : null;

        $allowed_formatters = array( 'integer', 'percent', 'duration_ms', 'plain' );
        $formatter          = isset( $kpi['formatter'] )
            ? (string) $kpi['formatter']
            : null;
        if ( null !== $formatter && ! in_array( $formatter, $allowed_formatters, true ) ) {
            $formatter = null;
        }

        return array(
            'id'            => $id,
            'label'         => $label,
            'data_endpoint' => $data_endpoint,
            'value_field'   => $this->sanitize_dot_path( $value_field ),
            'delta_field'   => null !== $delta_field ? $this->sanitize_dot_path( $delta_field ) : null,
            'suffix'        => $suffix,
            'invert_color'  => ! empty( $kpi['invert_color'] ),
            'priority'      => isset( $kpi['priority'] ) ? (int) $kpi['priority'] : 100,
            'formatter'     => $formatter,
        );
    }

    /**
     * Validate that a string is a relative REST path under the plugin namespace.
     *
     * Refuses absolute URLs (anything containing a scheme or `//` prefix) so a
     * malformed Pro descriptor cannot point the browser at a third-party host.
     *
     * @since 1.0.0
     *
     * @param string $path Candidate REST path, e.g. '/nimblymail-smtp/v1/dashboard/stats'.
     *
     * @return bool
     */
    private function is_valid_rest_path( string $path ): bool {
        if ( '' === $path ) {
            return false;
        }
        if ( preg_match( '#^(?:[a-z][a-z0-9+.-]*:)?//#i', $path ) ) {
            return false;
        }
        $namespace = '/' . self::NAMESPACE . '/';
        return 0 === strpos( $path, $namespace );
    }

    /**
     * Sanitise a dot-notation field path.
     *
     * Each segment is restricted to slug-friendly characters so a malformed
     * path cannot smuggle in object access tricks on the JS side.
     *
     * @since 1.0.0
     *
     * @param string $path Dot-notation key, e.g. 'previous.total'.
     *
     * @return string
     */
    private function sanitize_dot_path( string $path ): string {
        $segments = explode( '.', $path );
        $clean    = array();
        foreach ( $segments as $segment ) {
            $segment = preg_replace( '/[^A-Za-z0-9_]/', '', $segment );
            if ( null !== $segment && '' !== $segment ) {
                $clean[] = $segment;
            }
        }
        return implode( '.', $clean );
    }

    /**
     * Get dashboard stats for the requested period.
     *
     * When the optional `compare` query parameter is truthy the response
     * additionally carries a `previous` block containing the same shape for
     * the prior window of equal length. The current-window shape is
     * unchanged when `compare` is omitted, preserving backwards
     * compatibility with existing dashboards.
     *
     * @since 1.0.0 Added optional `compare` query param to include the prior-window stats under `previous`.
     *
     * @param WP_REST_Request $request Request.
     *
     * @return WP_REST_Response
     */
    public function get_stats( WP_REST_Request $request ): WP_REST_Response {
        $period = (string) $request->get_param( 'period' );
        $days   = self::PERIOD_DAYS[ $period ] ?? 7;

        $stats = Model::get_dashboard_stats( $days );

        $compare = (bool) $request->get_param( 'compare' );
        if ( $compare && $days > 0 ) {
            $previous = Model::get_dashboard_stats( $days, $days );
            // Trim to the comparable subset — `period` and `last_sent_at` are
            // not meaningful when comparing window-over-window.
            $stats['previous'] = array(
                'total'           => $previous['total'],
                'sent'            => $previous['sent'],
                'failed'          => $previous['failed'],
                'pending'         => $previous['pending'],
                'success_rate'    => $previous['success_rate'],
                'avg_duration_ms' => $previous['avg_duration_ms'],
            );
        }

        return new WP_REST_Response( $stats );
    }
}
