<?php

declare(strict_types=1);

namespace BotGuard\Services\Detection;

use BotGuard\Services\Detection\Contracts\DetectionModuleInterface;
use Throwable;

/**
 * The scoring orchestrator.
 *
 * Runs every enabled module over a SignalBag, combines their [0,1]
 * human-likelihood scores using the configured weights into a single 0-100
 * score, and classifies the session against the thresholds:
 *
 *     score = round( 100 * Σ(moduleScore × weight) / Σ(weight) )
 *
 * A module that throws is neutralised (0.5) rather than crashing verification,
 * so one buggy or misconfigured module cannot take down the gate.
 */
final class DetectionEngine
{
    /** @var array<int, array{key:string, weight:float, module:DetectionModuleInterface}> */
    private array $modules;

    /**
     * @param array<int, array{key:string, weight:float, module:DetectionModuleInterface}> $modules
     * @param int   $humanMin      Minimum score to be classified Human.
     * @param int   $suspiciousMin Minimum score to be classified Suspicious (below => Bot).
     * @param array<string,string> $actions Action per band (human/suspicious/bot).
     */
    public function __construct(
        array $modules,
        private int $humanMin,
        private int $suspiciousMin,
        private array $actions,
    ) {
        $this->modules = $modules;
    }

    /**
     * Evaluate the signals and return the aggregate outcome.
     */
    public function evaluate(SignalBag $signals): DetectionOutcome
    {
        $results = [];
        $weights = [];
        $weightedSum = 0.0;
        $totalWeight = 0.0;

        foreach ($this->modules as $entry) {
            $weight = (float) $entry['weight'];
            if ($weight <= 0) {
                continue;
            }

            $result = $this->runModule($entry['module'], $entry['key'], $signals);

            $results[] = $result;
            $weights[$result->key] = $weight;
            $weightedSum += $result->score * $weight;
            $totalWeight += $weight;
        }

        $score = $totalWeight > 0 ? (int) round(100 * ($weightedSum / $totalWeight)) : 0;
        $score = (int) clamp((float) $score, 0, 100);

        $classification = $this->classify($score);
        $action = $this->actions[$classification] ?? 'block';

        return new DetectionOutcome($score, $classification, $action, $results, $weights);
    }

    /**
     * Map a score to its classification band.
     */
    public function classify(int $score): string
    {
        if ($score >= $this->humanMin) {
            return 'human';
        }

        if ($score >= $this->suspiciousMin) {
            return 'suspicious';
        }

        return 'bot';
    }

    /**
     * Run one module defensively.
     */
    private function runModule(DetectionModuleInterface $module, string $key, SignalBag $signals): ModuleResult
    {
        try {
            return $module->analyze($signals);
        } catch (Throwable $e) {
            return ModuleResult::make(
                $key,
                0.5, // neutral: do not reward or punish on internal error
                [],
                ['module_error'],
                ['error' => $e->getMessage()],
            );
        }
    }
}
