<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\TagRebuildJob;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

final readonly class TagRebuildJobService
{
    /**
     * If a QUEUED job does not transition into RUNNING in time,
     * it is treated as stale so the system does not stay blocked forever.
     */
    private const STALE_QUEUED_AFTER_SECONDS = 300;

    /**
     * The background runner should switch the job from QUEUED to RUNNING almost
     * immediately because markRunning() happens at the top of the command.
     */
    private const ASYNC_START_TIMEOUT_SECONDS = 3;
    private const ASYNC_START_POLL_INTERVAL_MICROSECONDS = 250000;

    public function __construct(
        private EntityManagerInterface $em,
        private LoggerInterface $agentLogger,
        private string $projectDir,
    ) {
    }

    public function enqueueAndStartAsync(): TagRebuildJob
    {
        $job = new TagRebuildJob();

        $this->em->persist($job);
        $this->em->flush();

        try {
            $this->startAsync($job);
        } catch (\Throwable $e) {
            $job->markFailed('Async tag rebuild start failed: ' . $e->getMessage());
            $this->em->flush();

            $this->agentLogger->error('[tags] async job start failed', [
                'job' => (string) $job->getId(),
                'error' => $e->getMessage(),
            ]);

            throw $e;
        }

        return $job;
    }

    public function enqueueIfIdle(): ?TagRebuildJob
    {
        if ($this->hasActiveJob()) {
            return null;
        }

        return $this->enqueueAndStartAsync();
    }

    public function getLatestJob(): ?TagRebuildJob
    {
        return $this->em->createQueryBuilder()
            ->select('j')
            ->from(TagRebuildJob::class, 'j')
            ->orderBy('j.createdAt', 'DESC')
            ->addOrderBy('j.id', 'DESC')
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }

    public function getLatestCompletedJob(): ?TagRebuildJob
    {
        return $this->em->createQueryBuilder()
            ->select('j')
            ->from(TagRebuildJob::class, 'j')
            ->where('j.status = :status')
            ->setParameter('status', TagRebuildJob::STATUS_COMPLETED)
            ->orderBy('j.createdAt', 'DESC')
            ->addOrderBy('j.id', 'DESC')
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }

    public function hasActiveJob(): bool
    {
        $this->markStaleQueuedJobsFailed();

        $cutoff = new \DateTimeImmutable('-' . self::STALE_QUEUED_AFTER_SECONDS . ' seconds');

        $qb = $this->em->createQueryBuilder();
        $qb->select('COUNT(j.id)')
            ->from(TagRebuildJob::class, 'j')
            ->where(
                $qb->expr()->orX(
                    'j.status = :running',
                    $qb->expr()->andX(
                        'j.status = :queued',
                        'j.createdAt >= :cutoff'
                    )
                )
            )
            ->setParameter('running', TagRebuildJob::STATUS_RUNNING)
            ->setParameter('queued', TagRebuildJob::STATUS_QUEUED)
            ->setParameter('cutoff', $cutoff);

        return (int) $qb->getQuery()->getSingleScalarResult() > 0;
    }

    private function startAsync(TagRebuildJob $job): void
    {
        $projectDir = rtrim(trim($this->projectDir), '/');
        $console = $projectDir . '/bin/console';

        if ($projectDir === '' || !is_dir($projectDir)) {
            throw new \RuntimeException('Project directory is invalid.');
        }

        if (!is_file($console)) {
            throw new \RuntimeException('bin/console not found: ' . $console);
        }

        $phpBinary = $this->resolvePhpBinary();
        $jobId = (string) $job->getId();

        $logDir = $projectDir . '/var/log/tags';
        if (!is_dir($logDir) && !@mkdir($logDir, 0775, true) && !is_dir($logDir)) {
            throw new \RuntimeException('Could not create tag job log directory.');
        }

        $logFile = $logDir . '/job_' . $jobId . '.log';

        $cmd = sprintf(
            'cd %s && nohup %s %s %s %s --no-interaction >> %s 2>&1 & echo $!',
            escapeshellarg($projectDir),
            escapeshellarg($phpBinary),
            escapeshellarg($console),
            escapeshellarg('mto:agent:tags:job:run'),
            escapeshellarg($jobId),
            escapeshellarg($logFile)
        );

        $this->agentLogger->info('[tags] enqueue job async', [
            'job' => $jobId,
            'cmd' => $cmd,
            'log' => $logFile,
            'php_binary' => $phpBinary,
        ]);

        $output = [];
        $exitCode = 0;
        @exec($cmd, $output, $exitCode);

        $pid = isset($output[0]) ? trim((string) $output[0]) : '';

        if ($exitCode !== 0) {
            throw new \RuntimeException('Async process bootstrap failed with exit code ' . $exitCode . '.');
        }

        if ($pid === '' || !ctype_digit($pid)) {
            throw new \RuntimeException('Async process bootstrap did not return a valid PID.');
        }

        $this->agentLogger->info('[tags] async job process started', [
            'job' => $jobId,
            'pid' => $pid,
            'log' => $logFile,
            'php_binary' => $phpBinary,
        ]);

        $this->waitForAsyncJobTransition($job, $logFile);
    }

    private function resolvePhpBinary(): string
    {
        $envCandidates = [
            trim((string) ($_SERVER['PHP_CLI_BINARY'] ?? '')),
            trim((string) ($_ENV['PHP_CLI_BINARY'] ?? '')),
            trim((string) getenv('PHP_CLI_BINARY')),
        ];

        foreach ($envCandidates as $candidate) {
            if ($this->isValidCliPhpBinary($candidate)) {
                return $candidate;
            }
        }

        $phpBinary = defined('PHP_BINARY') ? trim((string) PHP_BINARY) : '';
        if ($this->isValidCliPhpBinary($phpBinary)) {
            return $phpBinary;
        }

        $fallbackCandidates = [
            '/usr/bin/php',
            '/usr/local/bin/php',
            '/bin/php',
            '/opt/homebrew/bin/php',
        ];

        foreach ($fallbackCandidates as $candidate) {
            if ($this->isValidCliPhpBinary($candidate)) {
                return $candidate;
            }
        }

        $whichPhp = trim((string) @shell_exec('command -v php 2>/dev/null'));
        if ($this->isValidCliPhpBinary($whichPhp)) {
            return $whichPhp;
        }

        throw new \RuntimeException(
            'Could not resolve a CLI PHP binary. Set PHP_CLI_BINARY explicitly, e.g. /usr/bin/php.'
        );
    }

    private function isValidCliPhpBinary(string $path): bool
    {
        $path = trim($path);

        if ($path === '' || !is_file($path) || !is_executable($path)) {
            return false;
        }

        $basename = strtolower(basename($path));

        if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) {
            return false;
        }

        return true;
    }

    private function markStaleQueuedJobsFailed(): void
    {
        $cutoff = new \DateTimeImmutable('-' . self::STALE_QUEUED_AFTER_SECONDS . ' seconds');

        $qb = $this->em->createQueryBuilder();
        $qb->select('j')
            ->from(TagRebuildJob::class, 'j')
            ->where('j.status = :queued')
            ->andWhere('j.createdAt < :cutoff')
            ->setParameter('queued', TagRebuildJob::STATUS_QUEUED)
            ->setParameter('cutoff', $cutoff)
            ->orderBy('j.createdAt', 'ASC')
            ->setMaxResults(25);

        /** @var list<TagRebuildJob> $stale */
        $stale = $qb->getQuery()->getResult();

        if ($stale === []) {
            return;
        }

        foreach ($stale as $job) {
            $jobId = (string) $job->getId();

            $job->markFailed('Stale QUEUED job detected (async start likely failed).');

            $this->agentLogger->warning('[tags] stale QUEUED job marked FAILED', [
                'job' => $jobId,
                'cutoff' => $cutoff->format(\DateTimeInterface::ATOM),
            ]);
        }

        $this->em->flush();
    }

    private function waitForAsyncJobTransition(TagRebuildJob $job, string $logFile): void
    {
        $deadline = microtime(true) + self::ASYNC_START_TIMEOUT_SECONDS;

        while (microtime(true) < $deadline) {
            usleep(self::ASYNC_START_POLL_INTERVAL_MICROSECONDS);
            $this->em->refresh($job);

            if (!$job->isQueued()) {
                return;
            }
        }

        $logHint = $this->readLogTail($logFile);

        throw new \RuntimeException(
            'Async tag rebuild runner did not transition from QUEUED to RUNNING within '
            . self::ASYNC_START_TIMEOUT_SECONDS
            . ' seconds.'
            . ($logHint !== null ? ' Log tail: ' . $logHint : '')
        );
    }

    private function readLogTail(string $logFile): ?string
    {
        if (!is_file($logFile) || !is_readable($logFile)) {
            return null;
        }

        $content = @file_get_contents($logFile);

        if (!is_string($content) || trim($content) === '') {
            return null;
        }

        $content = trim($content);
        $tail = mb_substr($content, -800);
        $tail = preg_replace('/\s+/u', ' ', $tail) ?? $tail;

        return trim($tail) !== '' ? trim($tail) : null;
    }
}