<?php

declare(strict_types=1);

namespace ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Controllers;

use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Dtos\VulnerabilityMeasureRowDto;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Repositories\VulnerabilityMeasuresRepository;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Services\Policies\VulnerabilitySeverityPolicy;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Services\VulnerabilityAutoUpdateService;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Services\VulnerabilityCurrentMeasureIndexBuilder;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Services\VulnerabilityMeasuresMailQueue;
use ReallySimplePlugins\RSS\Core\Features\Pro\Vulnerability\Services\VulnerabilityQuarantineService;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\VulnerabilityController;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Traits\HasScheduler;

/**
 * Orchestrates vulnerability mitigation measures on a debounced schedule.
 *
 * A measures run is scheduled:
 * - after the vulnerability sync completes, debounced to avoid duplicate runs
 * - and as a daily safety-net cron
 *
 * Flow (per vulnerability identifier):
 * 1) When a vulnerability is first detected, a measure row is created with:
 *    - start_date = now
 *    - end_date   = now + 24 hours
 *    - attempts   = 0
 *    - notified   = false
 * 2) On the next measures run after creation, we send a single email
 *    (`update_scheduled`) and set notified=true. This is the “vulnerabilities
 *    found / action will be taken” notification.
 * 3) After 24 hours (end_date has passed), we force an auto-update attempt.
 *    - If it succeeds: we queue `update_success` and mark the measure as fixed.
 *    - If it fails: attempts is incremented and end_date is postponed by 24 hours.
 * 4) After another 24 hours, we try again (max 2 attempts total).
 * 5) If the second update attempt also fails:
 *    - For measure=quarantine (typically Critical): quarantine the component,
 *      queue `quarantine`, and mark the measure as quarantined.
 *    - For any other measure (e.g. update-only, typically High): queue
 *      `end_process` and mark the measure as stopped.
 *
 * Measures that are fixed/quarantined/stopped are skipped on subsequent runs.
 */
final class VulnerabilityMeasuresController implements ControllerInterface
{
    use HasScheduler;

    /**
     * Action hook name used to trigger a scheduled vulnerability measures run.
     */
    private const MEASURES_EVENT = 'rsssl_pro_vulnerability_run_measures';

    /**
     * Action hook fired after vulnerability measures have completed.
     */
    public const MEASURES_COMPLETED_ACTION = 'rsssl_vulnerability_measures_completed';

    /**
     * Debounce window (in seconds) for scheduling vulnerability measures runs.
     */
    private const MEASURES_DEBOUNCE_SECONDS = (3 * MINUTE_IN_SECONDS);

    /**
     * Maximum number of auto-update attempts before stopping or quarantining.
     */
    private int $maxUpdateAttemptCount = 2;

    /**
     * Default duration (in seconds) for a newly created measure.
     */
    private int $runTime = 24 * HOUR_IN_SECONDS;

    /**
     * Seconds to postpone the next attempt after a failed update.
     */
    private int $postponeTime = 24 * HOUR_IN_SECONDS;

    private VulnerabilityMeasuresRepository $measuresRepository;
    private VulnerabilityAutoUpdateService $autoUpdateService;
    private VulnerabilityQuarantineService $quarantineService;
    private VulnerabilityMeasuresMailQueue $mailQueue;
    private VulnerabilityCurrentMeasureIndexBuilder $indexBuilder;
    private VulnerabilitySeverityPolicy $severityPolicy;

    public function __construct(
        VulnerabilityMeasuresRepository $measuresRepository,
        VulnerabilityAutoUpdateService $autoUpdateService,
        VulnerabilityQuarantineService $quarantineService,
        VulnerabilityCurrentMeasureIndexBuilder $indexBuilder,
        VulnerabilityMeasuresMailQueue $mailQueue,
        VulnerabilitySeverityPolicy $severityPolicy
    ) {
        $this->measuresRepository = $measuresRepository;
        $this->autoUpdateService = $autoUpdateService;
        $this->quarantineService = $quarantineService;
        $this->indexBuilder = $indexBuilder;
        $this->mailQueue = $mailQueue;
        $this->severityPolicy = $severityPolicy;
    }

    /**
     * Register hooks.
     *
     * Sync hook can pass args, scheduled run does not.
     */
    public function register(): void
    {
        add_action(VulnerabilityController::SYNC_COMPLETED_ACTION, [$this, 'scheduleMeasures']);
        add_action(self::MEASURES_EVENT, [$this, 'runScheduledMeasures'], 10, 0);
        // Safety net.
        add_action('rsssl_daily_cron', [$this, 'scheduleMeasures']);
    }

    /**
     * Queue measures after vulnerability sync.
     *
     * Uses a one-shot guard to avoid re-running measures immediately after
     * an auto-update triggers its own vulnerability refresh.
     */
    public function scheduleMeasures(...$args): void
    {
        if ($this->autoUpdateService->shouldSkipMeasures()) {
            return;
        }

        $this->scheduleDebounced(
            self::MEASURES_EVENT,
            self::MEASURES_DEBOUNCE_SECONDS,
        );
    }

    /**
     * Run the scheduled vulnerability measures. Hooked to {@see MEASURES_EVENT}
     * action that was scheduled via {@see scheduleMeasures()}.
     */
    public function runScheduledMeasures(): void
    {
        $this->run();

        // Release the debounce lock so future triggers can schedule again.
        $this->releaseDebounceLock(self::MEASURES_EVENT);

        // Fire completion action.
        do_action(self::MEASURES_COMPLETED_ACTION);
    }

    /**
     * Method flow:
     * 1. Stop and clear stored measures when no measure policy is enabled.
     * 2. Load stored measures from the repository.
     * 3. Build the current index of vulnerabilities.
     * 4. Remove measures that no longer exist.
     * 5. Add or update measures based on the current index.
     * 6. Execute time-based actions (notify once, auto-update, retry, quarantine/stop).
     * 7. Persist the updated measures back to storage.
     */
    private function run(): void
    {
        // Hard stop: no verified email or no enabled thresholds means no measures.
        if (!$this->severityPolicy->hasEnabledMeasures()) {
            $this->measuresRepository->saveAll([]);
            return;
        }

        $measures = $this->measuresRepository->getAll();
        $currentIndex = $this->indexBuilder->build();
        $measures = $this->cleanupMeasures($measures, array_keys($currentIndex));
        $measures = $this->upsertMeasures($measures, $currentIndex);
        $measures = $this->handleMeasures($measures);

        $this->mailQueue->flush();

        // Remove finalized measures from storage to keep the dataset small and to prevent re-processing.
        $measures = array_values(array_filter(
            $measures,
            static function (array $item): bool {
                $dto = VulnerabilityMeasureRowDto::fromArray($item);
                return $dto !== null && !$dto->isFinalized();
            }
        ));
        $this->measuresRepository->saveAll($measures);
    }

    /**
     * Remove measures that are no longer present in the current index.
     *
     * @param array<int, array<string, mixed>> $existing Stored measures.
     * @param list<string> $currentIdentifiers Identifiers from the current index.
     * @return array<int, array<string, mixed>>
     */
    private function cleanupMeasures(array $existing, array $currentIdentifiers): array
    {
        return array_values(array_filter(
            $existing,
            static function (array $item) use ($currentIdentifiers): bool {
                $dto = VulnerabilityMeasureRowDto::fromArray($item);

                if ($dto === null) {
                    // Drop invalid or corrupted rows.
                    return false;
                }

                return in_array($dto->getIdentifier(), $currentIdentifiers, true);
            }
        ));
    }

    /**
     * Synchronize stored measures with the current index.
     *
     * Existing measures keep their timing/attempt/notification state, while descriptive fields
     * (measure, risk, component details, type) are refreshed from the index.
     */
    private function upsertMeasures(array $existing, array $currentIndex): array
    {
        foreach ($currentIndex as $identifier => $current) {
            $key = $this->findMeasureKeyByIdentifier($existing, $identifier);
            if ($key === -1) {
                $existing[$identifier] = $this->createMeasureRow($identifier, $current);
                continue;
            }

            foreach (['measure', 'risk', 'component_name', 'component_slug', 'plugin_file', 'type'] as $field) {
                $existing[$key][$field] = $current[$field] ?? '';
            }
        }
        return $existing;
    }

    /**
     * Execute time-based actions for each stored measure.
     *
     * For each measure that is not fixed/quarantined/stopped:
     * - Queue a single `update_scheduled` email the first time we see the measure.
     * - When end_date has passed, attempt an auto-update.
     * - On success: mark fixed and queue `update_success`.
     * - On failure: postpone end_date by 24h and increment attempts.
     * - After 2 failed attempts:
     *   - If measure is `quarantine`: quarantine and queue `quarantine`.
     *   - Otherwise: stop the process and queue `end_process`.
     */
    private function handleMeasures(array $measures): array
    {
        $now = time();

        foreach ($measures as $key => $item) {
            $dto = VulnerabilityMeasureRowDto::fromArray($item);

            if ($dto === null) {
                // Skip invalid/partial rows silently to keep the scheduler robust.
                continue;
            }
            // Measures that are fixed/quarantined/stopped should not be acted upon again.
            if ($dto->isFinalized()) {
                continue;
            }

            $componentName = $dto->getComponentName();
            $measure = $dto->getMeasure();
            // Only notify once that a scheduled action will be taken.
            if ($dto->isNotified() === false) {
                $this->mailQueue->queue('update_scheduled', $componentName);
                $dto->markNotified();
            }

            // Only perform actions after the end_date has passed.
            if (!$dto->isActionAble($now)) {
                $measures[$key] = $dto->toArray();
                continue;
            }

            $success = $this->autoUpdateService->update([
                'type' => $dto->getType(),
                'plugin_file' => $dto->getPluginFile(),
                'component_slug' => $dto->getComponentSlug(),
            ]);

            if ($success) {
                $this->mailQueue->queue('update_success', $componentName);
                $dto->markFixed();
                $measures[$key] = $dto->toArray();
                continue;
            }

            $dto->incrementAttempts();
            $dto->postponeEndDate($now + $this->postponeTime);

            if ($dto->getAttempts() >= $this->maxUpdateAttemptCount) {
                if ($measure === 'quarantine') {
                    $this->quarantineService->quarantine([
                        'type' => $dto->getType(),
                        'component_slug' => $dto->getComponentSlug(),
                        'plugin_file' => $dto->getPluginFile(),
                    ]);
                    $this->mailQueue->queue('quarantine', $componentName);
                    $dto->markQuarantined();
                } else {
                    $this->mailQueue->queue('end_process', $componentName);
                    $dto->markStopped();
                }
            }

            $measures[$key] = $dto->toArray();
        }

        return $measures;
    }

    /**
     * Find the array key of a stored measure by its identifier and return
     * the measures key, or -1 if not found.
     */
    private function findMeasureKeyByIdentifier(array $measures, string $identifier): int
    {
        foreach ($measures as $idx => $row) {
            if ((string) ($row['identifier'] ?? '') === $identifier) {
                return $idx;
            }
        }

        return -1;
    }

    /**
     * Create a new stored measure from the current index entry.
     *
     * New measures start immediately with a 24 hour delay before the first
     * forced auto-update attempt. The first run that sees a new measure will
     * queue a single `update_scheduled` email (via the notified flag).
     */
    private function createMeasureRow(string $identifier, array $current): array
    {
        $now = time();

        $row = [
            'component_name' => (string) ($current['component_name'] ?? ''),
            'notified' => false,
            'component_slug' => (string) ($current['component_slug'] ?? ''),
            'plugin_file' => (string) ($current['plugin_file'] ?? ''),
            'type' => (string) ($current['type'] ?? ''),
            'measure' => (string) ($current['measure'] ?? ''),
            'risk' => (string) ($current['risk'] ?? ''),
            'identifier' => $identifier,
            'start_date' => $now,
            'end_date' => $now + $this->runTime,
            'attempts' => 0,
        ];
        return $row;
    }
}
