<?php
/**
 * Network dashboard stats aggregator.
 *
 * @package NimblyMail\SMTP
 *
 * @since 1.0.4
 */

namespace NimblyMail\SMTP\Network;

use NimblyMail\SMTP\Log\Model;
use NimblyMail\SMTP\Options;

defined( 'ABSPATH' ) || exit;

/**
 * Class StatsAggregator
 *
 * Computes network-wide email statistics without ever running the full
 * per-site switch_to_blog() loop inside an HTTP request. Networks at or
 * under FAST_PATH_THRESHOLD sites are aggregated inline — bounded and cheap
 * enough to stay inside a single request. Larger networks are aggregated in
 * CHUNK_SIZE-site slices via a self-rescheduling Action Scheduler action,
 * with partial progress persisted in a site transient between chunks and the
 * final snapshot written to TRANSIENT_KEY once every site has been visited.
 * Owns the cache transient's key and both of its TTLs; NetworkDashboardController
 * only ever reads them, keeping the dependency one-way.
 *
 * @since 1.0.4
 * @since 1.0.5 The progress transient no longer accumulates every processed
 *              site's row on every chunk write — that made per-chunk I/O grow
 *              linearly with progress through the run (O(n²) total). Each
 *              chunk now writes only its own rows to a small transient keyed
 *              by the offset it started at; the full site list is assembled
 *              once, during finalization, by merging those per-chunk
 *              transients.
 */
final class StatsAggregator {

    /**
     * Action Scheduler hook that processes one chunk of sites.
     *
     * @since 1.0.4
     *
     * @var string
     */
    public const HOOK = 'nimblymail_network_stats_chunk';

    /**
     * Action Scheduler group.
     *
     * @since 1.0.4
     *
     * @var string
     */
    public const GROUP = 'nimblymail-smtp';

    /**
     * Number of sites aggregated per background chunk run.
     *
     * @since 1.0.4
     *
     * @var int
     */
    public const CHUNK_SIZE = 30;

    /**
     * Networks at or under this site count are aggregated synchronously
     * inline instead of via the background job — bounded and cheap enough
     * to stay inside a single request, and avoids the cold-start "empty
     * dashboard" state for the overwhelming majority of installs.
     *
     * @since 1.0.4
     *
     * @var int
     */
    public const FAST_PATH_THRESHOLD = 50;

    /**
     * Site transient key for the cached network stats snapshot.
     *
     * @since 1.0.4
     *
     * @var string
     */
    public const TRANSIENT_KEY = 'nimblymail_network_stats';

    /**
     * Staleness interval in seconds: how long a snapshot is served as-is
     * before a background recompute is triggered. NOT how long the snapshot
     * is stored for — see SNAPSHOT_RETENTION_TTL, which governs that and is
     * deliberately much longer than this. A large network's full aggregation
     * run can take far longer than a short staleness window, so tying
     * storage lifetime to it would let the cache go empty — and the
     * dashboard blank — between a run finishing and the next one starting.
     * The snapshot is retained per SNAPSHOT_RETENTION_TTL and only ever
     * replaced, never allowed to expire into nothing; this constant only
     * decides when that replacement is triggered.
     *
     * @since 1.0.4
     *
     * @var int
     */
    public const STALE_AFTER = 300;

    /**
     * Site transient key holding in-progress chunk accumulation.
     *
     * @since 1.0.4
     *
     * @var string
     */
    private const PROGRESS_KEY = 'nimblymail_network_stats_progress';

    /**
     * Progress transient TTL. Generous relative to STALE_AFTER so a large
     * network's run — many short Action Scheduler ticks — has room to
     * finish before its partial state expires.
     *
     * @since 1.0.4
     *
     * @var int
     */
    private const PROGRESS_TTL = HOUR_IN_SECONDS;

    /**
     * How long a finished snapshot is retained in the cache transient. See
     * STALE_AFTER for why this is a separate, much longer duration.
     *
     * @since 1.0.4
     *
     * @var int
     */
    private const SNAPSHOT_RETENTION_TTL = DAY_IN_SECONDS;

    /**
     * Register the Action Scheduler hook.
     *
     * @since 1.0.4
     *
     * @return void
     */
    public function init(): void {
        add_action( self::HOOK, array( $this, 'process_chunk' ) );
    }

    /**
     * Cancel any scheduled chunk action.
     *
     * Call this from the deactivation hook, alongside Rotation::unschedule()
     * and friends, so no orphan chunk action remains queued after the
     * plugin is disabled.
     *
     * @since 1.0.4
     *
     * @return void
     */
    public static function unschedule(): void {
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
        }
    }

    /**
     * Write a computed snapshot to the cache transient.
     *
     * Single write path shared by the fast path (NetworkDashboardController)
     * and the chunked job's finalize step, so both retain the snapshot for
     * the same duration.
     *
     * @since 1.0.4
     *
     * @param array $data Snapshot in the compute_now()/finalize() shape.
     *
     * @return void
     */
    public static function store_snapshot( array $data ): void {
        set_site_transient( self::TRANSIENT_KEY, $data, self::SNAPSHOT_RETENTION_TTL );
    }

    /**
     * Whether a chunked aggregation run is currently scheduled or in flight.
     *
     * @since 1.0.4
     *
     * @return bool
     */
    public static function is_running(): bool {
        if ( ! function_exists( 'as_next_scheduled_action' ) ) {
            return false;
        }

        return false !== as_next_scheduled_action( self::HOOK, array(), self::GROUP );
    }

    /**
     * Kick off a background aggregation run if one is not already in flight.
     *
     * Idempotent by design: several admins loading a stale dashboard at
     * once, or one admin clicking Refresh repeatedly, must not restart or
     * duplicate the run — that would keep discarding partial progress and
     * the job could never finish on a large network. The enqueue call passes
     * $unique = true so Action Scheduler's own insert is atomic, closing the
     * race window between this method's is_running() check and the enqueue
     * (two near-simultaneous callers could otherwise both observe "not
     * running" and both start a chain). This is deliberately NOT applied to
     * the chunk-to-chunk reschedule in process_chunk() — that call fires
     * while the current chunk action is still RUNNING, and Action
     * Scheduler's uniqueness check treats RUNNING as blocking too, so a
     * unique insert there would be silently rejected and stall the chain
     * after a single chunk.
     *
     * @since 1.0.4
     *
     * @return bool True when a run is active (already running, or just
     *              started); false when Action Scheduler is unavailable and
     *              no background aggregation can happen at all.
     */
    public static function start_or_continue(): bool {
        if ( ! function_exists( 'as_enqueue_async_action' ) ) {
            return false;
        }

        if ( self::is_running() ) {
            return true;
        }

        set_site_transient( self::PROGRESS_KEY, self::empty_progress(), self::PROGRESS_TTL );

        as_enqueue_async_action( self::HOOK, array(), self::GROUP, true );

        return true;
    }

    /**
     * Action Scheduler callback. Aggregates the next CHUNK_SIZE sites,
     * persists progress, and either reschedules itself for the next chunk
     * or — once every site has been visited — writes the final snapshot to
     * the cache transient and clears the progress transient.
     *
     * @since 1.0.4
     * @since 1.0.5 The progress transient now stores only `cursor` and
     *              `totals` (O(1) per chunk); each chunk's own rows are
     *              written to a separate transient keyed by the offset the
     *              chunk started at. On finalization those per-chunk
     *              transients are merged into the full site list once, then
     *              deleted.
     *
     * @return void
     */
    public function process_chunk(): void {
        $progress = get_site_transient( self::PROGRESS_KEY );
        if ( ! is_array( $progress ) ) {
            $progress = self::empty_progress();
        }

        $cursor = (int) $progress['cursor'];
        $sites  = get_sites(
            array(
                'number' => self::CHUNK_SIZE,
                'offset' => $cursor,
            )
        );

        if ( empty( $sites ) ) {
            $all_sites = self::collect_chunk_sites( $cursor );
            $data      = self::finalize( (array) $progress['totals'], $all_sites );
            self::store_snapshot( $data );
            self::delete_chunk_transients( $cursor );
            delete_site_transient( self::PROGRESS_KEY );
            return;
        }

        [ $totals, $chunk_sites ] = self::aggregate_sites( $sites, (array) $progress['totals'], array() );

        set_site_transient( self::chunk_key( $cursor ), $chunk_sites, self::PROGRESS_TTL );

        set_site_transient(
            self::PROGRESS_KEY,
            array(
                'cursor' => $cursor + count( $sites ),
                'totals' => $totals,
            ),
            self::PROGRESS_TTL
        );

        if ( function_exists( 'as_enqueue_async_action' ) ) {
            as_enqueue_async_action( self::HOOK, array(), self::GROUP );
        }
    }

    /**
     * Aggregate synchronously and return the final response shape.
     *
     * Intended for networks at or under FAST_PATH_THRESHOLD, but the
     * get_sites() call is itself bounded to that many sites regardless of
     * caller — safe by construction rather than relying solely on callers
     * checking the site count first.
     *
     * @since 1.0.4
     *
     * @return array{totals: array, top_failing_sites: array, sites: array, cached_until: int}
     */
    public static function compute_now(): array {
        $all_sites = get_sites( array( 'number' => self::FAST_PATH_THRESHOLD ) );

        [ $totals, $sites ] = self::aggregate_sites( $all_sites, self::empty_totals(), array() );

        return self::finalize( $totals, $sites );
    }

    /**
     * Empty response shape for the cold-start case: no cache exists yet and
     * the network is large enough to require the background job. Lets the
     * REST controller respond immediately with a 'refreshing' status instead
     * of leaving the client with nothing to render while the first run completes.
     *
     * @since 1.0.4
     *
     * @return array{totals: array, top_failing_sites: array, sites: array, cached_until: int}
     */
    public static function empty_snapshot(): array {
        return array(
            'totals'            => self::empty_totals(),
            'top_failing_sites' => array(),
            'sites'             => array(),
            'cached_until'      => 0,
        );
    }

    /**
     * Zeroed totals shape.
     *
     * @since 1.0.4
     *
     * @return array{sent_24h: int, sent_7d: int, failed_24h: int, failed_7d: int}
     */
    private static function empty_totals(): array {
        return array(
            'sent_24h'   => 0,
            'sent_7d'    => 0,
            'failed_24h' => 0,
            'failed_7d'  => 0,
        );
    }

    /**
     * Starting accumulator for a chunked run: cursor at the first site with
     * nothing aggregated yet.
     *
     * @since 1.0.4
     * @since 1.0.5 Dropped the `sites` key — per-chunk rows now live in their
     *              own keyed transients (see chunk_key()) instead of being
     *              accumulated here.
     *
     * @return array{cursor: int, totals: array}
     */
    private static function empty_progress(): array {
        return array(
            'cursor' => 0,
            'totals' => self::empty_totals(),
        );
    }

    /**
     * Site transient key holding one chunk's own aggregated rows, keyed by
     * the offset that chunk started at.
     *
     * @since 1.0.5
     *
     * @param int $offset Offset the chunk started at.
     *
     * @return string
     */
    private static function chunk_key( int $offset ): string {
        return self::PROGRESS_KEY . '_' . $offset;
    }

    /**
     * Merge every per-chunk transient written so far into one flat site
     * list. Called once during finalization rather than once per chunk,
     * which is what keeps the merge cost O(n) total instead of O(n²).
     *
     * @since 1.0.5
     *
     * @param int $total_processed Number of sites processed so far (the
     *                              cursor value at finalization).
     *
     * @return array
     */
    private static function collect_chunk_sites( int $total_processed ): array {
        $sites = array();

        for ( $offset = 0; $offset < $total_processed; $offset += self::CHUNK_SIZE ) {
            $chunk_sites = get_site_transient( self::chunk_key( $offset ) );
            if ( is_array( $chunk_sites ) ) {
                $sites = array_merge( $sites, $chunk_sites );
            }
        }

        return $sites;
    }

    /**
     * Delete every per-chunk transient written during the run, once its rows
     * have been merged into the final snapshot.
     *
     * @since 1.0.5
     *
     * @param int $total_processed Number of sites processed so far (the
     *                              cursor value at finalization).
     *
     * @return void
     */
    private static function delete_chunk_transients( int $total_processed ): void {
        for ( $offset = 0; $offset < $total_processed; $offset += self::CHUNK_SIZE ) {
            delete_site_transient( self::chunk_key( $offset ) );
        }
    }

    /**
     * Switch into each given site in turn and accumulate its stats onto the
     * running totals/sites lists. Shared by the fast path and every
     * background chunk so both aggregate sites identically. Restores the
     * original blog and resets Options after each site, mirroring the
     * previous inline implementation.
     *
     * @since 1.0.4
     *
     * @param array $sites     Site objects from get_sites().
     * @param array $totals    Running totals to add to.
     * @param array $sites_acc Running per-site list to append to.
     *
     * @return array{0: array, 1: array} Updated [ totals, sites_acc ].
     */
    private static function aggregate_sites( array $sites, array $totals, array $sites_acc ): array {
        foreach ( $sites as $site ) {
            $blog_id = (int) $site->blog_id;

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

            $mailer = (string) Options::init()->get( 'mail', 'mailer' );
            $stats  = Model::get_range_stats( array( 1, 7 ) );

            $sent_24h   = $stats[1]['sent'];
            $failed_24h = $stats[1]['failed'];

            $totals['sent_24h']   += $sent_24h;
            $totals['sent_7d']    += $stats[7]['sent'];
            $totals['failed_24h'] += $failed_24h;
            $totals['failed_7d']  += $stats[7]['failed'];

            $sites_acc[] = array(
                'blog_id'    => $blog_id,
                'name'       => get_bloginfo( 'name' ),
                'mailer'     => $mailer,
                'sent_24h'   => $sent_24h,
                'failed_24h' => $failed_24h,
            );

            restore_current_blog();
        }

        Options::reset();

        return array( $totals, $sites_acc );
    }

    /**
     * Build the final response shape from accumulated totals/sites: derive
     * top_failing_sites and stamp cached_until.
     *
     * @since 1.0.4
     *
     * @param array $totals Accumulated totals.
     * @param array $sites  Accumulated per-site stats.
     *
     * @return array{totals: array, top_failing_sites: array, sites: array, cached_until: int}
     */
    private static function finalize( array $totals, array $sites ): array {
        $top_failing = array_filter(
            $sites,
            function ( array $site ) {
                return $site['failed_24h'] > 0;
            }
        );

        usort(
            $top_failing,
            function ( array $a, array $b ) {
                return $b['failed_24h'] <=> $a['failed_24h'];
            }
        );

        $top_failing = array_slice( $top_failing, 0, 5 );

        $top_failing_sites = array_map(
            function ( array $site ) {
                return array(
                    'blog_id'    => $site['blog_id'],
                    'name'       => $site['name'],
                    'failed_24h' => $site['failed_24h'],
                );
            },
            $top_failing
        );

        return array(
            'totals'            => $totals,
            'top_failing_sites' => $top_failing_sites,
            'sites'             => $sites,
            'cached_until'      => time() + self::STALE_AFTER,
        );
    }
}
