<?php

declare(strict_types=1);

namespace Apexl\Io\Jobs\Commands;

use Apexl\Config\ConfigurationInterface;
use Apexl\Io\Exceptions\RecordNotFoundException;
use Apexl\Io\Jobs\Dtos\QueueHandlerScope;
use Apexl\Io\Jobs\Dtos\QueueHandlerSummary;
use Apexl\Io\Jobs\Entities\QueueJob;
use Apexl\Io\Jobs\Interfaces\SequencedTaskHandlerInterface;
use Apexl\Io\Jobs\Services\QueueRecovery;
use Apexl\Io\System\Commands\DaedalusCommand;
use DateTimeInterface;
use InvalidArgumentException;
use Stringable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Operating the queue when it has stopped.
 *
 * A failed job is never retried — QueuedTaskFinderDb filters on `failed IS NULL` — so it
 * sits there until a person decides what to do about it. For a sequenced handler
 * ({@see SequencedTaskHandlerInterface}) it stops more than itself: everything behind it
 * defers rather than apply out of order, which is deliberate and means the queue only
 * restarts when somebody clears the job that broke.
 *
 * Nothing here is specific to one kind of job. The default scope is the whole queue —
 * or whatever `jobs.default_handlers` narrows it to, for an app where "the queue" means one
 * part of it — and `--handler` narrows it further, or reopens it with `all`.
 */
#[AsCommand(name: 'jobs:queue', description: 'Inspect and unblock the job queue')]
final class JobQueueCommand extends DaedalusCommand
{
    private const string ACTION__STATUS = 'status';
    private const string ACTION__SHOW = 'show';
    private const string ACTION__RETRY = 'retry';
    private const string ACTION__DISCARD = 'discard';
    private const string ACTION__SWEEP = 'sweep';
    private const string ACTION__HANDLERS = 'handlers';

    /**
     * Where a swept job is reported. `queue` is this module's own stream and is file-only,
     * which is the safe default but not usually the one anybody is watching — an app that
     * mails or posts a stream should point `jobs.alert_log_stream` at it.
     */
    private const string CONFIG__ALERT_STREAM = 'jobs.alert_log_stream';
    private const string DEFAULT__ALERT_STREAM = 'queue';

    /**
     * Handlers a run with no `--handler` covers. Unset, or null, is the whole queue.
     */
    private const string CONFIG__DEFAULT_HANDLERS = 'jobs.default_handlers';

    /** Longer payloads are cut to fit a table cell. */
    private const int PAYLOAD_WIDTH = 60;

    public function __construct(
        private readonly QueueRecovery $recovery,
        private readonly ConfigurationInterface $config,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument(
            'action',
            InputArgument::OPTIONAL,
            'status, handlers, show, retry, discard or sweep',
            self::ACTION__STATUS,
        );

        $this->addArgument(
            'id',
            InputArgument::OPTIONAL,
            'Job id, or "all" for retry and discard',
        );

        $this->addOption(
            'handler',
            null,
            InputOption::VALUE_REQUIRED,
            'Handler(s) to act on: a job class (the short name is enough), a comma-separated'
            . ' list, or "all". Defaults to jobs.default_handlers, or every handler.',
        );

        $this->setHelp(<<<'HELP'
            <info>status</info>   What has failed, and how much is waiting behind it.
            <info>handlers</info> Every handler with rows in the queue, and what state they are in.
                     These are the names <comment>--handler</comment> takes.
            <info>show</info>     The stored payload of a job, so you can see what would be applied.
            <info>discard</info>  Give up on a job. Marks it processed without running it — the work
                     it carried is never done, and the payload is kept as the only record
                     of it.
            <info>retry</info>    Put a job back in the queue. Where the handler is a sequenced one,
                     the backlog it was holding up comes forward with it.
            <info>sweep</info>    Fail any job left locked by a worker that died mid-run, so it is
                     reported instead of silently disappearing from the queue.

                     <comment>Only run sweep when no worker is running</comment> — a locked row is
                     indistinguishable from one legitimately in flight. Run it under the
                     same lock as the worker, which is the safe way to do this.

            Everything but <info>show</info> covers the whole queue unless <comment>--handler</comment> narrows
            it, or the app has narrowed the default through <comment>jobs.default_handlers</comment>;
            <info>show</info> takes an id, so it needs no scope. <comment>--handler=all</comment> always means all.

              <info>jobs:queue status --handler=EtlSyncJob</info>
              <info>jobs:queue retry all --handler=AddImageRotateJob</info>
            HELP);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $action = (string) $input->getArgument('action');
        $id = $input->getArgument('id');

        try {
            $scope = $this->scope($input);
        } catch (InvalidArgumentException $exception) {
            $output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));

            return Command::INVALID;
        }

        $this->announceScope($output, $scope, $action);

        return match ($action) {
            self::ACTION__STATUS => $this->status($output, $scope),
            self::ACTION__HANDLERS => $this->handlers($output),
            self::ACTION__SHOW => $this->show($output, $id),
            self::ACTION__RETRY => $this->retry($output, $id, $scope),
            self::ACTION__DISCARD => $this->discard($output, $id, $scope),
            self::ACTION__SWEEP => $this->sweep($output, $scope),
            default => $this->unknownAction($output, $action),
        };
    }

    /**
     * The handlers in play. The queue is only read for the handler list when --handler was
     * actually given: the default scope needs no lookup, and every run of this command is a
     * run against a database somebody is already worried about.
     *
     * @throws InvalidArgumentException When --handler names something that is not there.
     */
    private function scope(InputInterface $input): QueueHandlerScope
    {
        $option = $input->getOption('handler');

        if ($option === null) {
            return QueueHandlerScope::defaults($this->configuredHandlers());
        }

        return QueueHandlerScope::resolve((string) $option, $this->knownHandlers());
    }

    /**
     * The app's idea of what "the queue" means, if it has one.
     *
     * Read leniently: this is the command somebody reaches for when things are already
     * broken, and a config key that has been set to something odd is not a reason to refuse
     * to show them the queue.
     *
     * @return class-string[]|null
     */
    private function configuredHandlers(): ?array
    {
        if (!$this->config->has(self::CONFIG__DEFAULT_HANDLERS)) {
            return null;
        }

        $handlers = $this->config->get(self::CONFIG__DEFAULT_HANDLERS);

        return is_array($handlers) && $handlers !== [] ? array_values($handlers) : null;
    }

    /** @return string[] */
    private function knownHandlers(): array
    {
        return array_map(
            static fn (QueueHandlerSummary $summary): string => $summary->handler,
            $this->recovery->handlers(),
        );
    }

    /**
     * Say when the default is hiding part of the queue.
     *
     * A configured default is invisible otherwise: somebody looking at "nothing has failed"
     * has no way to know they are being shown two handlers out of nine. A scope that was
     * typed needs no such line — they wrote it.
     */
    private function announceScope(OutputInterface $output, QueueHandlerScope $scope, string $action): void
    {
        if ($scope->explicit || $scope->handlers === null || $action === self::ACTION__HANDLERS) {
            return;
        }

        $output->writeln(sprintf(
            '<comment>%s only</comment> — the configured default.'
            . ' <info>--handler=all</info> covers the rest of the queue.',
            $scope->label,
        ));
        $output->writeln('');
    }

    private function unknownAction(OutputInterface $output, string $action): int
    {
        $output->writeln(sprintf('<error>Unknown action "%s".</error> Try --help.', $action));

        return Command::INVALID;
    }

    private function status(OutputInterface $output, QueueHandlerScope $scope): int
    {
        $failed = $this->recovery->failed($scope->handlers);
        $blocked = $this->recovery->blockedCount($scope->handlers);
        $stale = $this->recovery->stale($scope->handlers);

        if ($stale !== []) {
            $output->writeln(sprintf(
                '<comment>%d job(s) are locked.</comment> If no worker is running, they were left by one'
                . ' that died — run <info>jobs:queue sweep</info>.',
                count($stale),
            ));
            $output->writeln('');
        }

        if ($failed === []) {
            $output->writeln(sprintf('<info>No failed %s.</info>', $this->noun($scope)));
            $output->writeln(sprintf('%d %s waiting to run.', $blocked, $this->noun($scope)));

            return Command::SUCCESS;
        }

        $output->writeln(sprintf(
            '<error>%d failed %s %s %d queued job(s).</error>',
            count($failed),
            $this->noun($scope),
            // Only a sequenced handler holds anything up; elsewhere the other jobs are
            // simply queued, and saying "blocking" would send someone looking for a barrier
            // that is not there.
            $this->anySequenced($failed) ? 'blocking' : 'alongside',
            $blocked,
        ));
        $output->writeln('');

        $table = new Table($output);
        $table->setHeaders(['id', 'sequence', 'handler', 'payload', 'attempts', 'failed']);

        foreach ($failed as $job) {
            $table->addRow([
                $job->id,
                $job->sequence,
                $this->shortHandler($job),
                $this->payloadSummary($job),
                $job->attempts,
                $this->moment($job->failed),
            ]);
        }

        $table->render();

        $output->writeln('');
        $output->writeln('Inspect one with <info>jobs:queue show <id></info>.');

        return Command::SUCCESS;
    }

    /**
     * What the jobs in scope are called in a sentence: "job(s)", "AddFileJob job(s)".
     *
     * Only a scope somebody typed names itself here. A configured default covering several
     * handlers would otherwise read as "3 failed EtlSyncJob, DeleteJob job(s)"; it gets a
     * line of its own instead.
     */
    private function noun(QueueHandlerScope $scope): string
    {
        return trim(($scope->explicit ? $scope->label : '') . ' job(s)');
    }

    /** Everything with rows in the queue — the names --handler accepts. */
    private function handlers(OutputInterface $output): int
    {
        $summaries = $this->recovery->handlers();

        if ($summaries === []) {
            $output->writeln('<info>The queue is empty.</info>');

            return Command::SUCCESS;
        }

        $table = new Table($output);
        $table->setHeaders(['handler', 'sequenced', 'queued', 'failed', 'deferred', 'locked']);

        foreach ($summaries as $summary) {
            $table->addRow([
                QueueHandlerScope::shortName($summary->handler),
                $this->isSequenced($summary->handler) ? 'yes' : '',
                $summary->queued,
                $summary->failed > 0 ? sprintf('<error>%d</error>', $summary->failed) : '0',
                $summary->deferred,
                $summary->locked,
            ]);
        }

        $table->render();

        $output->writeln('');
        $output->writeln('Scope any action to one with <info>--handler=<name></info>, or <info>--handler=all</info>.');

        return Command::SUCCESS;
    }

    /**
     * Whether a handler's jobs apply in sequence, and so hold each other up.
     *
     * Read from the class rather than a list kept here, because the answer belongs to the
     * handler: only it knows whether its jobs may overtake one another. A class that no
     * longer exists — a handler removed while its rows are still in the queue — is not
     * sequenced as far as anyone can now tell, which is the safe reading: it means the
     * backlog is left alone rather than reset.
     */
    private function isSequenced(string $handler): bool
    {
        return is_a($handler, SequencedTaskHandlerInterface::class, true);
    }

    /** @param QueueJob[] $jobs */
    private function anySequenced(array $jobs): bool
    {
        foreach ($jobs as $job) {
            if ($this->isSequenced((string) $job->handler)) {
                return true;
            }
        }

        return false;
    }

    private function shortHandler(QueueJob $job): string
    {
        return QueueHandlerScope::shortName((string) $job->handler);
    }

    /**
     * Enough of the payload to recognise the job.
     *
     * A payload that knows how to describe itself is asked — an ETL sync job naming its
     * table and row is worth more than the JSON around it — and anything else falls back to
     * its contents, short enough to sit in a table cell.
     */
    private function payloadSummary(QueueJob $job): string
    {
        $payload = $this->payload($job);

        if ($payload === null) {
            return '-';
        }

        if ($payload instanceof Stringable) {
            return (string) $payload;
        }

        $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);

        return mb_strlen($json) > self::PAYLOAD_WIDTH
            ? mb_substr($json, 0, self::PAYLOAD_WIDTH - 3) . '...'
            : $json;
    }

    /**
     * `created` is cast to DateTimeImmutable and `failed`/`processed` are not declared in
     * QueueJob::casts() at all — but they arrive as objects anyway, and casting one to a
     * string is a fatal. Formatted through here rather than assuming either shape, since
     * this command exists to be run when things are already broken.
     */
    private function moment(mixed $value): string
    {
        if ($value instanceof DateTimeInterface) {
            return $value->format('Y-m-d H:i:s');
        }

        return $value === null || $value === '' ? '-' : (string) $value;
    }

    /**
     * The decoded payload, or null. A job that was marked processed the ordinary way has had
     * its `contents` blanked by QueueJob::markProcessed(), so this is not always an object —
     * and reading a property off the empty string it leaves behind is a warning that would
     * scroll the useful output off the screen.
     */
    private function payload(QueueJob $job): ?object
    {
        return is_object($job->contents) ? $job->contents : null;
    }

    private function show(OutputInterface $output, mixed $id): int
    {
        $job = $this->find($output, $id);

        if ($job === null) {
            return Command::INVALID;
        }

        $output->writeln(sprintf('<info>Job %d</info> (%s)', $job->id, $this->shortHandler($job)));
        $output->writeln(sprintf('  sequence: %s', $job->sequence));
        $output->writeln(sprintf('  created:  %s', $this->moment($job->created)));
        $output->writeln(sprintf('  attempts: %d', $job->attempts));
        $output->writeln(sprintf('  failed:   %s', $this->moment($job->failed)));
        $output->writeln('');
        $output->writeln(json_encode($job->contents, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

        return Command::SUCCESS;
    }

    private function retry(OutputInterface $output, mixed $id, QueueHandlerScope $scope): int
    {
        $jobs = $this->targets($output, $id, $scope);

        if ($jobs === null) {
            return Command::INVALID;
        }

        foreach ($jobs as $job) {
            $this->recovery->retry($job);
            $output->writeln(sprintf('Requeued job <info>%d</info>.', $job->id));
        }

        $this->releaseBacklog($output, $scope);

        return Command::SUCCESS;
    }

    private function discard(OutputInterface $output, mixed $id, QueueHandlerScope $scope): int
    {
        $jobs = $this->targets($output, $id, $scope);

        if ($jobs === null) {
            return Command::INVALID;
        }

        foreach ($jobs as $job) {
            $output->writeln(sprintf(
                '<comment>Discarded job %d</comment> — %s will not be applied.',
                $job->id,
                $this->payloadSummary($job),
            ));

            $this->recovery->discard($job);
        }

        $this->releaseBacklog($output, $scope);

        return Command::SUCCESS;
    }

    /**
     * Bring the deferred backlog forward, once nothing in scope is failing.
     *
     * Only worth doing once the last barrier is gone: while one remains, everything brought
     * forward would simply defer again on its next attempt. And only for sequenced handlers
     * — those jobs are deferred by somebody else's failure and carry an attempt count that
     * blocking inflated, where a job of any other handler is backing off from its own and
     * clearing that count would loop it forever.
     */
    private function releaseBacklog(OutputInterface $output, QueueHandlerScope $scope): void
    {
        if ($this->recovery->failed($scope->handlers) !== []) {
            return;
        }

        $sequenced = $this->sequencedHandlers($scope);

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

        $released = $this->recovery->releaseDeferred($sequenced);

        $output->writeln(sprintf(
            '<info>Queue is clear.</info> %d deferred job(s) brought forward.',
            $released,
        ));
    }

    /**
     * The sequenced handlers this scope covers — the whole queue's, when it covers the
     * whole queue.
     *
     * @return class-string[]
     */
    private function sequencedHandlers(QueueHandlerScope $scope): array
    {
        return array_values(array_filter(
            $scope->handlers ?? $this->knownHandlers(),
            fn (string $handler): bool => $this->isSequenced($handler),
        ));
    }

    private function sweep(OutputInterface $output, QueueHandlerScope $scope): int
    {
        $stale = $this->recovery->stale($scope->handlers);

        if ($stale === []) {
            $output->writeln('<info>No locked jobs.</info>');

            return Command::SUCCESS;
        }

        foreach ($stale as $job) {
            $summary = $this->payloadSummary($job);

            $this->recovery->failStale($job);

            // This line is what turns a silent stall into something somebody hears about, so
            // it goes to whichever stream the app actually watches.
            \Apexl\Io\Logger\logger($this->alertStream())->error(
                'Job {task} was left locked by a worker that did not finish',
                [
                    'task' => sprintf('%s#%d', $this->shortHandler($job), $job->id),
                    'payload' => $summary,
                ],
            );

            $output->writeln(sprintf('<comment>Failed stale job %d</comment>.', $job->id));
        }

        $output->writeln('');
        $output->writeln('Decide each one with <info>jobs:queue show|retry|discard</info>.');

        return Command::SUCCESS;
    }

    private function alertStream(): string
    {
        return $this->config->has(self::CONFIG__ALERT_STREAM)
            ? (string) $this->config->get(self::CONFIG__ALERT_STREAM)
            : self::DEFAULT__ALERT_STREAM;
    }

    /**
     * @return QueueJob[]|null Null when the argument does not identify anything.
     */
    private function targets(OutputInterface $output, mixed $id, QueueHandlerScope $scope): ?array
    {
        if ($id === 'all') {
            $failed = $this->recovery->failed($scope->handlers);

            if ($failed === []) {
                $output->writeln('<info>No failed jobs.</info>');
            }

            return $failed;
        }

        $job = $this->find($output, $id);

        return $job === null ? null : [$job];
    }

    private function find(OutputInterface $output, mixed $id): ?QueueJob
    {
        if ($id === null) {
            $output->writeln('<error>A job id is required.</error>');

            return null;
        }

        try {
            return $this->recovery->job((int) $id);
        } catch (RecordNotFoundException) {
            $output->writeln(sprintf('<error>No job with id %s.</error>', $id));

            return null;
        }
    }
}
