<?php

namespace App\Services;

use App\Events\ServerStatusChanged;
use App\Models\LocationCheckResult;
use App\Models\ProbeLocation;
use App\Models\Server;
use App\Models\Website;
use App\Services\FlapDetector;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;

/**
 * Multi-location consensus engine.
 *
 * The problem: a single probe might see a target as down when it's actually
 * up — Cloudflare blocks our IP, BTRC submarine cable cut, ISP routing
 * issue, or just transient packet loss between two specific networks. If
 * we trust one probe's word, we wake up on-call for "outages" that aren't.
 *
 * The solution: every active probe location reports its check results.
 * We compute a per-target "consensus_status" by counting how many recent
 * results from distinct locations say UP vs DOWN.
 *
 *   up         All healthy probes report UP
 *   partial    Some probes report DOWN but most still see UP
 *   down       Majority of probes report DOWN — confirmed outage
 *   unknown    Not enough recent data to decide (e.g. just added)
 *
 * Threshold for "down": at least 2 distinct locations AND > 50% of
 * reporting locations agree. This is the "two-witness rule" — we never
 * page on-call from a single location's word, but we don't require ALL
 * locations to fail (a regional outage in one region is real but we
 * should reflect it as "partial" not "down").
 *
 * Recent = last 5 minutes. Older results are stale and excluded.
 */
class ConsensusService
{
    /** Look-back window for "is the target up right now?" */
    public const RECENT_WINDOW_MINUTES = 5;

    /** Minimum number of locations that must report DOWN before we mark
     *  the target as confirmed down. With only 1 active probe, we still
     *  reflect its result but stay in "partial" — we cannot confirm without
     *  a second witness. */
    public const MIN_DOWN_LOCATIONS = 2;

    /**
     * Recompute consensus for a target and persist the new state. Called
     * by a scheduled job after each probe-result push, or directly after
     * the manual ping service records a result.
     *
     * Returns the new consensus_status.
     */
    public function recompute(Model $target): string
    {
        $expectedLocations = $this->expectedLocationsFor($target);
        $totalExpected = max(1, count($expectedLocations));

        if (empty($expectedLocations)) {
            // No probes available at all (e.g. brand-new install with no
            // active locations). Default to "unknown" so the dashboard can
            // surface it as "set up monitoring locations to begin."
            $this->saveConsensus($target, 0, 0, 'unknown');
            return 'unknown';
        }

        $cutoff = now()->subMinutes(self::RECENT_WINDOW_MINUTES);

        // Pull latest result PER location for this target. We use the most
        // recent row from each location — older results from the same
        // location don't double-count.
        $latestPerLocation = LocationCheckResult::query()
            ->where('target_type', $target->getMorphClass())
            ->where('target_id', $target->getKey())
            ->whereIn('probe_location_id', $expectedLocations)
            ->where('checked_at', '>=', $cutoff)
            ->orderBy('checked_at', 'desc')
            ->get()
            ->groupBy('probe_location_id')
            ->map(fn ($group) => $group->first());  // first() = newest because we sorted desc

        $reporting = $latestPerLocation->count();
        $down = $latestPerLocation->where('is_up', false)->count();
        $up = $reporting - $down;

        $newStatus = $this->classifyConsensus($up, $down, $reporting, $totalExpected);

        $this->saveConsensus($target, $totalExpected, $down, $newStatus);

        return $newStatus;
    }

    /**
     * Decide consensus_status from the vote counts.
     */
    protected function classifyConsensus(int $up, int $down, int $reporting, int $totalExpected): string
    {
        // Not enough data to decide — fewer than half of expected locations
        // have reported recently. Stay "unknown" instead of false alarming.
        if ($reporting < max(1, intdiv($totalExpected, 2))) {
            return 'unknown';
        }

        if ($down === 0) {
            return 'up';
        }

        if ($up === 0) {
            // Every reporting location says down. Confirmed if we have
            // at least MIN_DOWN_LOCATIONS independent witnesses.
            return $reporting >= self::MIN_DOWN_LOCATIONS ? 'down' : 'partial';
        }

        // Majority vote tiebreaker:
        // - More than half reporting down + at least MIN_DOWN_LOCATIONS = down
        // - Otherwise = partial (regional issue, some locations affected)
        if ($down > $up && $down >= self::MIN_DOWN_LOCATIONS) {
            return 'down';
        }

        return 'partial';
    }

    /**
     * Persist consensus state on the target row + fire ServerStatusChanged
     * if the status actually changed. ServerStatusChanged is what triggers
     * incident creation, push notifications, etc.
     */
    protected function saveConsensus(Model $target, int $totalExpected, int $reportingDown, string $newStatus): void
    {
        $previousStatus = $target->status ?? 'unknown';
        $previousConsensus = $target->consensus_status ?? 'unknown';

        // Map consensus to top-level "status" column. The dashboard already
        // colors rows by the existing status column, so reusing it keeps
        // the UI consistent — "consensus_status" stays as detail.
        $statusMap = [
            'up' => 'online',
            'partial' => 'warning',
            'down' => 'offline',
            'unknown' => $previousStatus, // don't downgrade existing data on a stale window
        ];

        $target->fill([
            'consensus_status' => $newStatus,
            'consensus_updated_at' => now(),
            'locations_total' => $totalExpected,
            'locations_reporting_down' => $reportingDown,
        ]);

        if (isset($statusMap[$newStatus]) && $newStatus !== 'unknown') {
            $target->status = $statusMap[$newStatus];
        }

        $target->save();

        // Fire status-changed event ONLY for servers (Website doesn't have
        // its own event yet — its checker handles its own status flips).
        if ($target instanceof Server && $previousStatus !== $target->status) {
            // Record this transition with the flap detector. The detector
            // tracks how often this server has flipped recently — the
            // CreateIncidentFromStatusChange listener uses isFlapping() to
            // decide whether to suppress new incidents. We record the
            // transition here regardless of flap state so the counter
            // stays accurate for future decisions.
            try {
                app(FlapDetector::class)->recordTransition($target, $previousStatus, $target->status);
            } catch (\Throwable $e) {
                Log::warning('FlapDetector record failed: ' . $e->getMessage());
            }

            try {
                event(new ServerStatusChanged($target, $previousStatus));
            } catch (\Throwable $e) {
                Log::warning('Failed to broadcast consensus status change: ' . $e->getMessage());
            }
        }
    }

    /**
     * Resolve which probe locations are EXPECTED to check this target.
     *
     * - If the target has explicit probeLocations() pivot rows, those win.
     * - Otherwise, all currently-active probe locations apply (default).
     *
     * Decommissioned/paused locations never count toward consensus.
     */
    protected function expectedLocationsFor(Model $target): array
    {
        $explicit = $target->probeLocations()
            ->where('probe_locations.status', 'active')
            ->pluck('probe_locations.id')
            ->all();

        if (!empty($explicit)) {
            return $explicit;
        }

        return ProbeLocation::active()->pluck('id')->all();
    }

    /**
     * Recompute consensus for ALL active monitored entities. Called by
     * the scheduler every minute. Bounded by a per-tick cap so a huge
     * fleet doesn't pile up DB queries.
     */
    public function recomputeAll(int $maxPerTick = 200): array
    {
        $stats = ['servers' => 0, 'websites' => 0];

        Server::active()
            ->orderBy('consensus_updated_at')
            ->limit($maxPerTick)
            ->each(function (Server $s) use (&$stats) {
                $this->recompute($s);
                $stats['servers']++;
            });

        Website::where('is_active', true)
            ->orderBy('consensus_updated_at')
            ->limit($maxPerTick)
            ->each(function (Website $w) use (&$stats) {
                $this->recompute($w);
                $stats['websites']++;
            });

        return $stats;
    }
}
