<?php
/**
 * REST API Network Sites Controller.
 *
 * @package NimblyMail\SMTP
 *
 * @since 1.0.0
 */

namespace NimblyMail\SMTP\Rest\Network;

use NimblyMail\SMTP\Log\Model;
use NimblyMail\SMTP\Log\Rotation;
use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\SettingsProvider;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

defined( 'ABSPATH' ) || exit;

/**
 * Class NetworkSitesController
 *
 * Powers the Network Admin → NimblyMail → Sites table and per-site override
 * editor. Exposes a paginated site list with per-site email stats and a
 * read/write endpoint for each site's effective settings overrides.
 *
 * @since 1.0.0
 * @since 1.0.4 A page of the site list is now served from a short-lived
 *              cache so a repeat request skips the per-site work; the total
 *              count stays live so pagination still reflects sites that were
 *              just added or removed. Saving a site's overrides now also
 *              records the rotation-configured marker when the save touches
 *              that group.
 * @since 1.0.5 Every switch_to_blog()/restore_current_blog() region now uses
 *              try/finally so the site context is always restored, even when
 *              the switched-context work throws. A network-wide version
 *              counter, bumped on every successful update_site_settings()
 *              save, is now folded into the sites-list cache key so an edit
 *              invalidates previously cached pages instead of waiting out
 *              SITES_CACHE_TTL.
 */
class NetworkSitesController extends AbstractNetworkController {

    /**
     * How long, in seconds, a cached page of the site list stays valid.
     *
     * Kept short because the summary shown for each site changes slowly. As
     * of 1.0.5 an edit via update_site_settings() bumps the cache version and
     * invalidates cached pages immediately rather than waiting out this TTL
     * — this TTL now only bounds staleness from other causes (e.g. a site's
     * stats changing via new mail activity). This cache and the network
     * dashboard's StatsAggregator snapshot are populated independently and
     * share no source of truth, so the same site's sent/failed counts can
     * still disagree between the two screens for up to
     * SITES_CACHE_TTL + StatsAggregator::STALE_AFTER seconds (300 + 300 =
     * 600s worst case) — see issue #66, which defers a full merge of the two
     * per-site loops as a larger redesign.
     *
     * @since 1.0.4
     * @since 1.0.5 Documented the combined staleness bound vs the network
     *              dashboard now that edit-triggered invalidation no longer
     *              makes this TTL the only source of staleness.
     *
     * @var int
     */
    private const SITES_CACHE_TTL = 300;

    /**
     * Network option name for the sites-list cache-key version counter.
     *
     * Bumped by bump_cache_version() on every successful
     * update_site_settings() save, and folded into build_sites_cache_key()
     * so a bump makes all previously cached pages unreachable — they simply
     * expire on their own TTL as orphaned entries rather than needing to be
     * enumerated and deleted. A persistent option, not a transient: it must
     * not expire independently of the cache pages it versions.
     *
     * @since 1.0.5
     *
     * @var string
     */
    private const CACHE_VERSION_OPTION = 'nimblymail_network_sites_cache_version';

    /**
     * Register the REST routes for sites management.
     *
     * @since 1.0.0
     *
     * @return void
     */
    protected function do_register_routes(): void {
        register_rest_route(
            self::NAMESPACE,
            '/network/sites',
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => array( $this, 'get_sites' ),
                'permission_callback' => array( $this, 'permissions_check' ),
            )
        );

        register_rest_route(
            self::NAMESPACE,
            '/network/sites/(?P<blog_id>[\d]+)/settings',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_site_settings' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'blog_id' => array(
                            'required'          => true,
                            'type'              => 'integer',
                            'sanitize_callback' => 'absint',
                        ),
                    ),
                ),
                array(
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => array( $this, 'update_site_settings' ),
                    'permission_callback' => array( $this, 'permissions_check' ),
                    'args'                => array(
                        'blog_id' => array(
                            'required'          => true,
                            'type'              => 'integer',
                            'sanitize_callback' => 'absint',
                        ),
                    ),
                ),
            )
        );
    }

    /**
     * Return a paginated list of network sites with per-site summary data.
     *
     * Query parameters:
     *   - page     (int, default 1,  min 1)
     *   - per_page (int, default 50, min 1, max 100)
     *
     * Response shape:
     *   {
     *     "sites":    [ { "blog_id", "name", "domain", "path", "mailer",
     *                     "override_count", "sent_24h", "failed_24h" }, ... ],
     *     "total":    17,
     *     "page":     1,
     *     "per_page": 50
     *   }
     *
     * A warm cache serves the rows assembled by an earlier request and skips
     * the per-site work entirely. The total is always recounted — it is a
     * single cheap query, and keeping it live means pagination reflects sites
     * that were just added or removed even while the rows are cached.
     *
     * @since 1.0.0
     * @since 1.0.4 A page of assembled rows is now cached briefly so a repeat
     *              request skips the per-site work.
     * @since 1.0.5 The per-site data-gathering work now runs inside a
     *              try/finally so restore_current_blog() always runs, even if
     *              one site's data throws — otherwise the rest of the request
     *              (including later loop iterations) stays pinned to that
     *              site.
     *
     * @param WP_REST_Request $request The API request.
     *
     * @return WP_REST_Response
     */
    public function get_sites( WP_REST_Request $request ): WP_REST_Response {
        $page_param     = $request->get_param( 'page' );
        $per_page_param = $request->get_param( 'per_page' );
        $page           = max( 1, $page_param ? (int) $page_param : 1 );
        $per_page       = min( 100, max( 1, $per_page_param ? (int) $per_page_param : 50 ) );
        $offset         = ( $page - 1 ) * $per_page;

        $total = (int) get_sites( array( 'count' => true ) );

        $cache_key = self::build_sites_cache_key( $page, $per_page );
        $cached    = get_site_transient( $cache_key );

        if ( is_array( $cached ) ) {
            $result = $cached;
        } else {
            $sites = get_sites(
                array(
                    'number' => $per_page,
                    'offset' => $offset,
                )
            );

            $result = array();

            foreach ( $sites as $site ) {
                $blog_id = (int) $site->blog_id;

                switch_to_blog( $blog_id );
                Options::reset();

                try {
                    $name           = get_bloginfo( 'name' );
                    $mailer         = Options::init()->get( 'mail', 'mailer' );
                    $raw_options    = get_option( Options::OPTION_KEY, array() );
                    $override_count = $this->count_option_leaves( is_array( $raw_options ) ? $raw_options : array() );
                    $stats          = Model::get_dashboard_stats( 1 );
                } finally {
                    // Guarantees the site context is restored even if any of
                    // the calls above throws, so a single bad site can't leave
                    // the rest of this request (and any later loop iteration)
                    // pinned to the wrong blog.
                    restore_current_blog();
                    Options::reset();
                }

                $result[] = array(
                    'blog_id'        => $blog_id,
                    'name'           => $name,
                    'domain'         => $site->domain,
                    'path'           => $site->path,
                    'mailer'         => $mailer,
                    'override_count' => $override_count,
                    'sent_24h'       => (int) ( $stats['sent'] ?? 0 ),
                    'failed_24h'     => (int) ( $stats['failed'] ?? 0 ),
                );
            }

            set_site_transient( $cache_key, $result, self::SITES_CACHE_TTL );
        }

        return new WP_REST_Response(
            array(
                'sites'    => $result,
                'total'    => $total,
                'page'     => $page,
                'per_page' => $per_page,
            )
        );
    }

    /**
     * Build the cache key for one page of the site list.
     *
     * Keyed by page number, page size, and the current cache version so
     * requests for different pages do not collide and a version bump makes
     * every previously cached page unreachable. Kept short because WordPress
     * prefixes the stored key and the column it is stored in is limited to
     * 191 characters — a version integer adds negligible length.
     *
     * @since 1.0.4
     * @since 1.0.5 Folded in the cache version so update_site_settings() can
     *              invalidate cached pages by bumping it.
     *
     * @param int $page     Requested page number.
     * @param int $per_page Requested page size.
     *
     * @return string
     */
    private static function build_sites_cache_key( int $page, int $per_page ): string {
        return sprintf( 'nimblymail_network_sites_v%d_p%d_n%d', self::get_cache_version(), $page, $per_page );
    }

    /**
     * Current sites-list cache version.
     *
     * @since 1.0.5
     *
     * @return int
     */
    private static function get_cache_version(): int {
        return (int) get_site_option( self::CACHE_VERSION_OPTION, 1 );
    }

    /**
     * Bump the sites-list cache version, invalidating every previously
     * cached page.
     *
     * @since 1.0.5
     *
     * @return void
     */
    private static function bump_cache_version(): void {
        update_site_option( self::CACHE_VERSION_OPTION, self::get_cache_version() + 1 );
    }

    /**
     * Return the settings tabs schema for a specific network site.
     *
     * Response shape:
     *   { "tabs": [...], "locks": {...}, "blog_id": N, "blog_name": "..." }
     *
     * @since 1.0.0
     * @since 1.0.5 The switched-context work now runs inside a try/finally so
     *              restore_current_blog() always runs, even if building the
     *              tabs/locks payload throws.
     *
     * @param WP_REST_Request $request The API request.
     *
     * @return WP_REST_Response|WP_Error
     */
    public function get_site_settings( WP_REST_Request $request ) {
        $blog_id = (int) $request->get_param( 'blog_id' );

        $site = get_site( $blog_id );
        if ( ! $site ) {
            return new WP_Error(
                'rest_site_not_found',
                __( 'Site not found.', 'nimblymail-smtp' ),
                array( 'status' => 404 )
            );
        }

        switch_to_blog( $blog_id );
        Options::reset();

        try {
            $tabs      = SettingsProvider::get_instance()->get_tabs( 'network-site' );
            $locks     = Options::init()->get_locks();
            $blog_name = get_bloginfo( 'name' );
        } finally {
            // Guarantees the site context is restored even if any of the
            // calls above throws.
            restore_current_blog();
            Options::reset();
        }

        return new WP_REST_Response(
            array(
                'tabs'      => $tabs,
                'locks'     => $locks,
                'blog_id'   => $blog_id,
                'blog_name' => $blog_name,
            )
        );
    }

    /**
     * Write setting overrides for a specific network site.
     *
     * Body shape: { "group": { "key": value, ... }, ... }
     *
     * Unknown groups or fields are rejected with 400. Fields defined by PHP
     * constants are silently dropped. Encrypted fields arriving as the mask
     * sentinel are skipped so stored ciphertext is preserved.
     *
     * Response shape:
     *   { "message": "Site settings saved.", "tabs": [...], "blog_id": N }
     *
     * @since 1.0.0
     * @since 1.0.4 Records the rotation-configured marker on this blog when
     *              the persisted overrides touch the rotation group.
     * @since 1.0.5 The switched-context work now runs inside a try/finally so
     *              restore_current_blog() always runs — previously an
     *              exception from validate_against_schema(), the
     *              nimblymail_settings_pre_save filter, or Options::set()
     *              (which can throw RuntimeException on unconfigured
     *              encryption salts) would skip it and leave the request
     *              pinned to the wrong blog. A successful save now also bumps
     *              the sites-list cache version so the Sites list stops
     *              serving pre-edit rows instead of waiting out
     *              SITES_CACHE_TTL.
     *
     * @param WP_REST_Request $request The API request.
     *
     * @return WP_REST_Response|WP_Error
     */
    public function update_site_settings( WP_REST_Request $request ) {
        $blog_id = (int) $request->get_param( 'blog_id' );

        $site = get_site( $blog_id );
        if ( ! $site ) {
            return new WP_Error(
                'rest_site_not_found',
                __( 'Site not found.', 'nimblymail-smtp' ),
                array( 'status' => 404 )
            );
        }

        $body = $request->get_json_params();
        if ( ! is_array( $body ) ) {
            return new WP_Error(
                'rest_invalid_param',
                __( 'Invalid data provided.', 'nimblymail-smtp' ),
                array( 'status' => 400 )
            );
        }

        switch_to_blog( $blog_id );
        Options::reset();

        try {
            $whitelist = $this->build_field_whitelist( 'network-site' );
            $validated = $this->validate_against_schema( $body, $whitelist, true );

            if ( is_wp_error( $validated ) ) {
                return $validated;
            }

            // Pre-save filter — same hook as single-site SettingsController, so
            // Pro's SettingsLock (and any other add-on) can strip locked groups
            // before persistence on per-site override saves too.
            $validated = apply_filters( 'nimblymail_settings_pre_save', $validated, $request );
            if ( is_wp_error( $validated ) ) {
                return $validated;
            }

            // update_blog_option writes directly; no switch needed inside set(). We
            // stay switched to render the fresh tabs for the response, and that
            // same switch is what makes the marker below land on this blog's own
            // options table rather than the current admin's.
            Options::init()->set( $validated, 'network-site', $blog_id );
            Rotation::maybe_mark_configured( $validated );
            self::bump_cache_version();
            Options::reset();

            $tabs      = SettingsProvider::get_instance()->get_tabs( 'network-site' );
            $blog_name = get_bloginfo( 'name' );
        } finally {
            // Guarantees the site context is restored on every exit path from
            // the try block above — the two early returns, the normal
            // completion, and (unlike before this fix) any exception thrown
            // by validate_against_schema(), the nimblymail_settings_pre_save
            // filter, or Options::set() (which can throw RuntimeException on
            // unconfigured encryption salts).
            restore_current_blog();
            Options::reset();
        }

        $response_data = array(
            'message'   => __( 'Site settings saved.', 'nimblymail-smtp' ),
            'tabs'      => $tabs,
            'blog_id'   => $blog_id,
            'blog_name' => $blog_name,
        );

        // Response filter — lets SettingsLock attach warnings about stripped
        // Pro groups so super-admins editing site overrides see the same
        // feedback as direct site-admin saves.
        $filtered = apply_filters( 'nimblymail_settings_save_response', $response_data, $validated, $request );
        if ( is_array( $filtered ) ) {
            $response_data = $filtered;
        }

        return new WP_REST_Response( $response_data );
    }

    /**
     * Count all leaf values across a two-level associative options array.
     *
     * Used to compute the override_count for each site in the sites list.
     * Only scalar (non-array) values are counted as leaves.
     *
     * @since 1.0.0
     *
     * @param array $options The raw options array from get_option().
     *
     * @return int Total number of leaf settings stored for the site.
     */
    private function count_option_leaves( array $options ): int {
        $count = 0;

        foreach ( $options as $group ) {
            if ( ! is_array( $group ) ) {
                continue;
            }
            foreach ( $group as $value ) {
                if ( ! is_array( $value ) ) {
                    ++$count;
                }
            }
        }

        return $count;
    }
}
