<?php

namespace App\Services\Queue;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Detects whether a Laravel queue worker is actually consuming jobs.
 *
 * Background: this app uses the database queue driver. Jobs sit in the
 * `jobs` table waiting for `php artisan queue:work` to claim them. If
 * cron or supervisor isn't configured (very common on shared hosting,
 * first-time installs, or after a deploy that didn't restart the
 * scheduler), nothing pulls the jobs — campaigns flip to "sending" and
 * sit there forever.
 *
 * This service answers the question "is the worker for queue X alive?"
 * BEFORE a feature flips the parent record into a sending/processing
 * state, so users see a clear, actionable error instead of a stuck UI.
 *
 * Two-signal detection:
 *   1. Heartbeat: every job pings a cache key on `handle()` start. If
 *      the key is fresh (< HEARTBEAT_TTL old), the worker is alive.
 *   2. Stale-job sweep: if heartbeat is missing/cold, check the `jobs`
 *      table for old unreserved rows on that queue. Old + unreserved =
 *      the worker isn't claiming, so it's dead.
 *
 * Both signals matter because a freshly-started worker with no jobs
 * yet has no heartbeat — and we don't want to false-positive in that
 * "cold start, empty queue" case.
 */
class QueueHealthService
{
    /** Cache key prefix for the per-queue heartbeat. */
    public const HEARTBEAT_PREFIX = 'queue:heartbeat:';

    /** Heartbeat is considered "fresh" for this many seconds. */
    public const HEARTBEAT_TTL = 300; // 5 minutes — wider than scheduler tick (1 min)

    /** A job older than this on the queue with no `reserved_at` = the worker is not claiming. */
    public const STALE_JOB_THRESHOLD_SECONDS = 90;

    /**
     * Record that a worker on this queue just claimed/started a job.
     * Called at the top of every job's handle() method.
     */
    public function ping(string $queue): void
    {
        Cache::put(
            self::HEARTBEAT_PREFIX . $queue,
            now()->timestamp,
            now()->addSeconds(self::HEARTBEAT_TTL * 2)
        );
    }

    /**
     * True if the queue is healthy enough to accept new jobs without
     * them being stranded. False = no worker is consuming.
     */
    public function isQueueAlive(string $queue): bool
    {
        // Sync driver runs jobs inline — always healthy.
        if (config('queue.default') === 'sync') {
            return true;
        }

        // Signal 1 — fresh heartbeat means a job ran in the last 5 min.
        $heartbeat = (int) Cache::get(self::HEARTBEAT_PREFIX . $queue, 0);
        if ($heartbeat > 0 && (now()->timestamp - $heartbeat) <= self::HEARTBEAT_TTL) {
            return true;
        }

        // Signal 2 — look at the jobs table. If there are NO stale
        // unclaimed rows on this queue, the worker is either keeping
        // up or there's nothing to do — both are "alive enough" for a
        // new dispatch to land safely. We only declare it dead when
        // there's clear evidence: rows that became available > 90s ago
        // and never got picked up.
        try {
            $staleCount = DB::table('jobs')
                ->where('queue', $queue)
                ->whereNull('reserved_at')
                ->where('available_at', '<', now()->subSeconds(self::STALE_JOB_THRESHOLD_SECONDS)->timestamp)
                ->count();

            return $staleCount === 0;
        } catch (\Throwable $e) {
            // If the `jobs` table is missing (e.g. redis driver, custom
            // setup), don't block the user — fall back to "assume alive"
            // since the absence of evidence is not evidence of failure.
            Log::warning('QueueHealthService: jobs table check failed', [
                'queue' => $queue,
                'error' => $e->getMessage(),
            ]);
            return true;
        }
    }

    /**
     * Throws a `QueueWorkerDownException` if the queue isn't being
     * consumed. The exception carries the specific queue name plus a
     * suggested fix string so callers (and API controllers) can render
     * an actionable message instead of a generic 500.
     */
    public function assertHealthy(string $queue): void
    {
        if ($this->isQueueAlive($queue)) {
            return;
        }

        throw new QueueWorkerDownException($queue);
    }

    /**
     * Summary used by admin dashboards / diagnostics.
     */
    public function status(string $queue): array
    {
        $heartbeat = (int) Cache::get(self::HEARTBEAT_PREFIX . $queue, 0);
        $heartbeatAge = $heartbeat > 0 ? (now()->timestamp - $heartbeat) : null;

        $pendingCount = 0;
        $staleCount = 0;
        try {
            $pendingCount = DB::table('jobs')->where('queue', $queue)->count();
            $staleCount = DB::table('jobs')
                ->where('queue', $queue)
                ->whereNull('reserved_at')
                ->where('available_at', '<', now()->subSeconds(self::STALE_JOB_THRESHOLD_SECONDS)->timestamp)
                ->count();
        } catch (\Throwable $e) {
            // Driver doesn't expose a jobs table — fine, leave zero.
        }

        return [
            'queue'           => $queue,
            'driver'          => config('queue.default'),
            'alive'           => $this->isQueueAlive($queue),
            'heartbeat_age_s' => $heartbeatAge,
            'pending_jobs'    => $pendingCount,
            'stale_jobs'      => $staleCount,
        ];
    }
}
