<?php declare(strict_types=1);

namespace App\Controller\Jury;

use App\Controller\BaseController;
use App\DataTransferObject\SubmissionRestriction;
use App\Doctrine\DBAL\Types\JudgeTaskType;
use App\Entity\Contest;
use App\Entity\DebugPackage;
use App\Entity\Executable;
use App\Entity\ExternalJudgement;
use App\Entity\Judgehost;
use App\Entity\JudgeTask;
use App\Entity\Judging;
use App\Entity\JudgingRun;
use App\Entity\Language;
use App\Entity\Problem;
use App\Entity\Submission;
use App\Entity\SubmissionFile;
use App\Entity\SubmissionSource;
use App\Entity\Team;
use App\Entity\TeamAffiliation;
use App\Entity\TeamCategory;
use App\Entity\Testcase;
use App\Entity\TestcaseGroup;
use App\Form\Type\SubmissionsFilterType;
use App\Service\AuthorizedUserService;
use App\Service\BalloonService;
use App\Service\ConfigurationService;
use App\Service\DOMJudgeService;
use App\Service\EventLogService;
use App\Service\ScoreboardService;
use App\Service\SubmissionService;
use App\Utils\Utils;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join;
use Knp\Component\Pager\Pagination\PaginationInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_JURY')]
#[Route(path: '/jury')]
class SubmissionController extends BaseController
{
    use JudgeRemainingTrait;

    public function __construct(
        protected readonly AuthorizedUserService $authService,
        EntityManagerInterface $em,
        protected readonly EventLogService $eventLogService,
        DOMJudgeService $dj,
        protected readonly ConfigurationService $config,
        protected readonly SubmissionService $submissionService,
        protected readonly RouterInterface $router,
        KernelInterface $kernel,
    ) {
        parent::__construct($em, $eventLogService, $dj, $kernel);
    }

    // Legacy route - redirects to contest-scoped route
    #[Route(path: '/submissions', name: 'jury_submissions_legacy')]
    public function legacyIndexAction(Request $request): Response
    {
        $contest = $this->dj->getCurrentContest();
        if (!$contest) {
            $this->addFlash('warning', 'Please select a contest first to view submissions.');
            return $this->redirectToRoute('jury_index');
        }
        return $this->redirectToRoute('jury_submissions', array_merge(
            ['contestId' => $contest->getExternalid()],
            $request->query->all()
        ));
    }

    #[Route(path: '/contests/{contestId}/submissions', name: 'jury_submissions')]
    public function indexAction(
        Request $request,
        string $contestId,
        #[MapQueryParameter(name: 'view')]
        ?string $viewFromRequest = null,
    ): Response {
        $contest = $this->dj->getContestByExternalId($contestId);

        $viewTypes = [0 => 'all', 1 => 'unverified', 2 => 'unjudged', 3 => 'judging'];
        $view      = 0;
        if (($submissionViewCookie = $this->dj->getCookie('domjudge_submissionview')) &&
            isset($viewTypes[$submissionViewCookie])) {
            $view = $submissionViewCookie;
        }

        if ($viewFromRequest) {
            $index = array_search($viewFromRequest, $viewTypes);
            if ($index !== false) {
                $view = $index;
            }
        }

        $response = $this->dj->setCookie('domjudge_submissionview', (string)$view);

        $refresh = [
            'after' => 15,
            'url' => $this->generateUrl('jury_submissions', [
                'contestId' => $contestId,
                'view' => $viewTypes[$view],
                'page' => $request->query->getInt('page', 1),
            ]),
            'ajax' => true,
        ];

        $restrictions = new SubmissionRestriction();
        if ($viewTypes[$view] == 'unverified') {
            $restrictions->verified = false;
        }
        if ($viewTypes[$view] == 'unjudged') {
            $restrictions->judged = false;
        }
        if ($viewTypes[$view] == 'judging') {
            $restrictions->judging = true;
        }

        $contests = [$contest->getExternalid() => $contest];

        // Load preselected filters
        $filtersFromCookie = Utils::jsonDecode((string)$this->dj->getCookie('domjudge_submissionsfilter') ?: '[]');

        $formAssociationFields = [
            'problem_id' => Problem::class,
            'language_id' => Language::class,
            'team_id' => Team::class,
            'category_id' => TeamCategory::class,
            'affiliation_id' => TeamAffiliation::class,
        ];

        // Build the filter form.
        $filtersForForm = ['result' => $filtersFromCookie['result'] ?? []];
        $hasFilters = !empty($filtersForForm['result']);
        foreach ($formAssociationFields as $field => $entityClass) {
            $filtersForForm[$field] = $this->em->getRepository($entityClass)->findBy(['externalid' => $filtersFromCookie[$field] ?? []]);
            $hasFilters = $hasFilters || !empty($filtersForForm[$field]);
        }
        $appliedFilters = $filtersForForm;
        $form = $this->createForm(SubmissionsFilterType::class, array_merge($filtersForForm, [
            "contests" => $contests,
        ]));

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $filtersForCookie = ['result' => $form->get('result')->getData()];
            $hasFilters = !empty($filtersForCookie['result']);
            $appliedFilters = [];
            foreach ($formAssociationFields as $field => $entityClass) {
                $filtersForCookie[$field] = array_map(fn($entity) => $entity->getExternalid(), $form->get($field)->getData());
                $hasFilters = $hasFilters || !empty($filtersForCookie[$field]);
                $appliedFilters[$field] = $form->get($field)->getData();
            }
            $response = $this->dj->setCookie('domjudge_submissionsfilter', Utils::jsonEncode($filtersForCookie), response: $response);
        }

        if (!empty($appliedFilters['result'])) {
            $restrictions->results = $appliedFilters['result'];
        }
        if (!empty($appliedFilters['problem_id'])) {
            $restrictions->problemIds = $appliedFilters['problem_id'];
        }
        if (!empty($appliedFilters['language_id'])) {
            $restrictions->languageIds = $appliedFilters['language_id'];
        }
        if (!empty($appliedFilters['team_id'])) {
            $restrictions->teamIds = $appliedFilters['team_id'];
        }
        if (!empty($appliedFilters['category_id'])) {
            $restrictions->categoryIds = $appliedFilters['category_id'];
        }
        if (!empty($appliedFilters['affiliation_id'])) {
            $restrictions->affiliationIds = $appliedFilters['affiliation_id'];
        }

        /** @var PaginationInterface<int, Submission> $submissions */
        [$submissions, $submissionCounts] =
            $this->submissionService->getSubmissionList(
                $contests,
                $restrictions,
                page: $request->query->getInt('page', 1),
            );
        $disabledProblems = [];
        $disabledLangs = [];
        foreach ($submissions as $submission) {
            if (!$submission->getContestProblem()->getAllowJudge()) {
                $disabledProblems[$submission->getProblem()->getExternalid()] = $submission->getProblem()->getName();
            }
            if (!$submission->getLanguage()->getAllowJudge()) {
                $disabledLangs[$submission->getLanguage()->getExternalid()] = $submission->getLanguage()->getName();
            }
        }

        $results = array_keys($this->config->getVerdicts(['final', 'in_progress']));

        $data = [
            'refresh' => $refresh,
            'contestId' => $contestId,
            'viewTypes' => $viewTypes,
            'view' => $view,
            'submissions' => $submissions,
            'submissionCounts' => $submissionCounts,
            'showContest' => count($contests) > 1,
            'hasFilters' => $hasFilters,
            'results' => $results,
            'showExternalResult' => $this->dj->shadowMode(),
            'showTestcases' => true,
            'disabledProbs' => $disabledProblems,
            'disabledLangs' => $disabledLangs,
        ];

        // For ajax requests, only return the submission list partial.
        if ($request->isXmlHttpRequest()) {
            return $this->render('jury/partials/submission_list.html.twig', $data);
        }

        $data["form"] = $form->createView();

        return $this->render('jury/submissions.html.twig', $data, $response);
    }

    // Legacy route - redirects to contest-scoped route
    #[Route(path: '/submissions/{submitId}', name: 'jury_submission_legacy')]
    public function legacyViewAction(
        Request $request,
        string $submitId,
    ): Response {
        // If "no contest" is explicitly selected, redirect to index
        if ($this->dj->getCurrentContestCookie() === '-1') {
            $this->addFlash('warning', 'Please select a contest first to view submissions.');
            return $this->redirectToRoute('jury_index');
        }

        // Look up the submission to find its contest
        /** @var Submission|null $submission */
        $submission = $this->em->getRepository(Submission::class)
            ->findOneBy(['externalid' => $submitId]);

        if (!$submission) {
            throw new NotFoundHttpException(sprintf('No submission found with ID %s', $submitId));
        }

        return $this->redirectToRoute('jury_submission', array_merge(
            [
                'contestId' => $submission->getContest()->getExternalid(),
                'submitId' => $submitId,
            ],
            $request->query->all()
        ));
    }

    /**
     * @throws NonUniqueResultException
     */
    #[Route(path: '/contests/{contestId}/submissions/{submitId}', name: 'jury_submission')]
    public function viewAction(
        Request $request,
        string $contestId,
        string $submitId,
        #[MapQueryParameter(name: 'jid')]
        ?int $judgingId = null,
        #[MapQueryParameter(name: 'rejudgingid')]
        ?int $rejudgingId = null,
    ): Response {
        $contest = $this->dj->getContestByExternalId($contestId);

        if (isset($judgingId, $rejudgingId)) {
            throw new BadRequestHttpException("You cannot specify jid and rejudgingid at the same time.");
        }

        // If judging ID is not set but rejudging ID is, try to deduce the judging ID from the database.
        if (!isset($judgingId) && isset($rejudgingId)) {
            /** @var Judging|null $judging */
            $judging = $this->em->createQueryBuilder()
                ->from(Judging::class, 'j')
                ->select('j')
                ->innerJoin('j.submission', 's')
                ->andWhere('j.rejudging = :rejudgingId')
                ->andWhere('s.externalid = :submitId')
                ->andWhere('s.contest = :contest')
                ->setParameter('rejudgingId', $rejudgingId)
                ->setParameter('submitId', $submitId)
                ->setParameter('contest', $contest)
                // A submission can have more than one judging within the same
                // rejudging, for example when a judging was aborted or
                // requeued after an internal error. Make this more robust by
                // picking the most recent one.
                ->orderBy('j.judgingid', 'DESC')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();
            if ($judging) {
                $judgingId = $judging->getJudgingid();
            }
        }

        /** @var Submission|null $submission */
        $submission = $this->em->createQueryBuilder()
            ->from(Submission::class, 's')
            ->join('s.team', 't')
            ->join('s.problem', 'p')
            ->join('s.language', 'l')
            ->join('s.contest', 'c')
            ->leftJoin('s.user', 'u')
            ->leftJoin('s.files', 'f')
            ->leftJoin('s.external_judgements', 'ej', Join::WITH, 'ej.valid = 1')
            ->leftJoin('s.contest_problem', 'cp')
            ->leftJoin('s.originalSubmission', 'os')
            ->select('s', 'os', 't', 'u', 'p', 'l', 'c', 'f', 'cp', 'ej')
            ->andWhere('s.externalid = :submitid')
            ->andWhere('s.contest = :contest')
            ->setParameter('contest', $contest)
            ->setParameter('submitid', $submitId)
            ->getQuery()
            ->getOneOrNullResult();

        if (!$submission) {
            throw new NotFoundHttpException(sprintf('No submission found with ID %s', $submitId));
        }

        $judgingData = $this->em->createQueryBuilder()
            ->from(Judging::class, 'j', 'j.judgingid')
            ->leftJoin('j.runs', 'jr')
            ->leftJoin('j.rejudging', 'r')
            ->select('j', 'r', 'MAX(jr.runtime) AS max_runtime')
            ->andWhere('j.contest = :contest')
            ->andWhere('j.submission = :submission')
            ->setParameter('contest', $submission->getContest())
            ->setParameter('submission', $submission)
            ->groupBy('j.judgingid')
            ->orderBy('j.judgingid')
            ->getQuery()
            ->getResult();

        // These three arrays are indexed by judgingid
        /** @var Judging[] $judgings */
        $judgings    = array_map(fn($data) => $data[0], $judgingData);
        $maxRunTimes = array_map(fn($data) => $data['max_runtime'], $judgingData);
        $timelimits  = [];

        if ($judgings) {
            $judgeTasks = $this->em->createQueryBuilder()
                ->from(JudgeTask::class, 'jt', 'jt.jobid')
                ->select('jt')
                ->andWhere('jt.jobid IN (:jobIds)')
                ->andWhere('jt.type = :type')
                ->setParameter(
                    'jobIds',
                    array_map(static fn(Judging $judging) => $judging->getJudgingid(), $judgings)
                )
                ->setParameter('type', JudgeTaskType::JUDGING_RUN)
                ->getQuery()
                ->getResult();
            $timelimits = array_map(function (JudgeTask $task) {
                return Utils::jsonDecode($task->getRunConfig())['time_limit'];
            }, $judgeTasks);
        }

        $selectedJudging = null;
        // Find the selected judging.
        if ($judgingId !== null) {
            $selectedJudging = $judgings[$judgingId] ?? null;
        } else {
            foreach ($judgings as $judging) {
                if ($judging->getValid()) {
                    $selectedJudging = $judging;
                }
            }
        }

        $claimWarning = null;

        if ($request->get('claim') || $request->get('unclaim')) {
            if ($response = $this->processClaim($selectedJudging, $request, $claimWarning)) {
                return $response;
            }
        }

        if ($request->get('claimdiff') || $request->get('unclaimdiff')) {
            $externalJudgement = $submission->getExternalJudgements()->first();
            if ($response = $this->processClaim($externalJudgement, $request, $claimWarning)) {
                return $response;
            }
        }

        $outputDisplayLimit    = (int)$this->config->get('output_display_limit');
        $outputTruncateMessage = sprintf("\n[output display truncated after %d B]\n", $outputDisplayLimit);

        $externalRuns = [];
        if ($externalJudgement = $submission->getExternalJudgements()->first()) {
            $queryBuilder = $this->em->createQueryBuilder()
                ->from(Testcase::class, 't')
                ->leftJoin('t.external_runs', 'er', Join::WITH, 'er.external_judgement = :judging')
                ->select('t', 'er')
                ->andWhere('t.problem = :problem')
                ->setParameter('judging', $externalJudgement)
                ->setParameter('problem', $submission->getProblem())
                ->orderBy('t.ranknumber');

            $externalRunResults = $queryBuilder
                ->getQuery()
                ->getResult();

            foreach ($externalRunResults as $externalRunResult) {
                $externalRuns[] = $externalRunResult;
            }
        }

        $judgehosts = $this->em->createQueryBuilder()
            ->from(JudgeTask::class, 'jt')
            ->join('jt.judgehost', 'jh')
            ->select('jh.judgehostid', 'jh.hostname')
            ->andWhere('jt.judgehost IS NOT NULL')
            ->andWhere('jt.jobid = :judging')
            ->setParameter('judging', $selectedJudging)
            ->groupBy('jh.hostname')
            ->orderBy('jh.hostname')
            ->getQuery()
            ->getScalarResult();
        $judgehosts = array_combine(
            array_column($judgehosts, 'judgehostid'),
            array_column($judgehosts, 'hostname')
        );

        $runsOutstanding = false;
        $runs       = [];
        $runsOutput = [];
        $sameTestcaseHashes = true;
        if ($selectedJudging || $externalJudgement) {
            $queryBuilder = $this->em->createQueryBuilder()
                ->from(Testcase::class, 't')
                ->join('t.content', 'tc')
                ->leftJoin('t.judging_runs', 'jr', Join::WITH, 'jr.judging = :judging')
                ->leftJoin('jr.output', 'jro')
                ->select('t', 'jr', 'tc.image_thumb AS image_thumb', 'jro.metadata', 'jro.validatorMetadata', 'jr.pass')
                ->andWhere('t.problem = :problem')
                ->setParameter('judging', $selectedJudging)
                ->setParameter('problem', $submission->getProblem())
                ->orderBy('t.ranknumber');
            if ($outputDisplayLimit < 0) {
                $queryBuilder
                    ->addSelect('tc.output AS output_reference')
                    ->addSelect('jro.output_run AS output_run')
                    ->addSelect('jro.output_diff AS output_diff')
                    ->addSelect('jro.output_error AS output_error')
                    ->addSelect('jro.team_message As team_message')
                    ->addSelect('jro.output_system AS output_system');
            } else {
                $queryBuilder
                    ->addSelect('TRUNCATE(tc.output, :outputDisplayLimit, :outputTruncateMessage) AS output_reference')
                    ->addSelect('TRUNCATE(jro.output_run, :outputDisplayLimit, :outputTruncateMessage) AS output_run')
                    ->addSelect('RIGHT(jro.output_run, 50) AS output_run_last_bytes')
                    ->addSelect('TRUNCATE(jro.output_diff, :outputDisplayLimit, :outputTruncateMessage) AS output_diff')
                    ->addSelect('TRUNCATE(jro.output_error, :outputDisplayLimit, :outputTruncateMessage) AS output_error')
                    ->addSelect('TRUNCATE(jro.team_message, :outputDisplayLimit, :outputTruncateMessage) AS team_message')
                    ->addSelect('TRUNCATE(jro.output_system, :outputDisplayLimit, :outputTruncateMessage) AS output_system')
                    ->setParameter('outputDisplayLimit', $outputDisplayLimit)
                    ->setParameter('outputTruncateMessage', $outputTruncateMessage);
            }

            $runResults = $queryBuilder
                ->getQuery()
                ->getResult();

            $judgingRunTestcaseHashesInOrder = $this->em->createQueryBuilder()
                ->from(JudgeTask::class, 'jt')
                ->select('jt.testcase_hash')
                ->andWhere('jt.jobid = :judging')
                ->andWhere('jt.type = :type')
                ->setParameter('judging', $selectedJudging)
                ->setParameter('type', JudgeTaskType::JUDGING_RUN)
                ->orderBy('jt.judgetaskid')
                ->getQuery()
                ->getScalarResult();

            $cnt = 0;
            if (count($judgingRunTestcaseHashesInOrder) !== count($runResults)) {
                $sameTestcaseHashes = false;
            }
            foreach ($runResults as $runResult) {
                /** @var Testcase $testcase */
                $testcase = $runResult[0];
                if (isset($judgingRunTestcaseHashesInOrder[$cnt])) {
                    if ($testcase->getTestcasehash() != $judgingRunTestcaseHashesInOrder[$cnt]['testcase_hash']) {
                        $sameTestcaseHashes = false;
                    }
                }
                $cnt++;
                /** @var JudgingRun|null $firstJudgingRun */
                $firstJudgingRun = $runResult[0]->getFirstJudgingRun();
                if ($firstJudgingRun !== null && $firstJudgingRun->getRunresult() === null) {
                    $runsOutstanding = true;
                }
                $runs[] = $runResult[0];
                unset($runResult[0]);
                if (!empty($runResult['metadata'])) {
                    $metadata = Utils::parseMetadata($runResult['metadata']);
                    $runResult['output_limit'] = $metadata['output-truncated'] ?? 'n/a';
                }
                $runResult['terminated'] = preg_match('/timelimit exceeded.*hard (wall|cpu) time/',
                                                      (string)$runResult['output_system']);
                $runResult['hostname'] = null;
                $runResult['judgehostid'] = null;
                if ($firstJudgingRun && $firstJudgingRun->getJudgeTask() && $firstJudgingRun->getJudgeTask()->getJudgehost()) {
                    $runResult['hostname'] = $firstJudgingRun->getJudgeTask()->getJudgehost()->getHostname();
                    $runResult['judgehostid'] = $firstJudgingRun->getJudgeTask()->getJudgehost()->getJudgehostid();
                }
                $runResult['is_output_run_truncated_in_db'] = preg_match(
                    '/\[output storage truncated after \d* B\]/',
                    $outputDisplayLimit >= 0 ?
                        (string)$runResult['output_run_last_bytes'] : (string)$runResult['output_run']
                );
                if ($firstJudgingRun) {
                    $runResult['testcasedir'] = $firstJudgingRun->getTestcaseDir();
                }
                $runsOutput[] = $runResult;
            }
        }

        if ($submission->getOriginalSubmission()) {
            $lastSubmission = $submission->getOriginalSubmission();
        } else {
            /** @var Submission|null $lastSubmission */
            $lastSubmission = $this->em->createQueryBuilder()
                ->from(Submission::class, 's')
                ->select('s')
                ->andWhere('s.team = :team')
                ->andWhere('s.problem = :problem')
                ->andWhere('s.submittime < :submittime')
                ->setParameter('team', $submission->getTeam())
                ->setParameter('problem', $submission->getProblem())
                ->setParameter('submittime', $submission->getSubmittime())
                ->orderBy('s.submittime', 'DESC')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();
        }

        /** @var Judging|null $lastJudging */
        $lastJudging = null;
        /** @var Testcase[] $lastRuns */
        $lastRuns = [];
        /** @var array<int, JudgingRun> $lastRunsByTestcaseId */
        $lastRunsByTestcaseId = [];
        if ($lastSubmission !== null) {
            $lastJudging = $this->em->createQueryBuilder()
                ->from(Judging::class, 'j')
                ->select('j')
                ->andWhere('j.submission = :submission')
                ->andWhere('j.valid = 1')
                ->setParameter('submission', $lastSubmission)
                ->orderBy('j.judgingid', 'DESC')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();

            if ($lastJudging !== null) {
                $lastRuns = $this->em->createQueryBuilder()
                    ->from(Testcase::class, 't')
                    ->select('t')
                    ->andWhere('t.problem = :problem')
                    ->setParameter('problem', $submission->getProblem())
                    ->orderBy('t.ranknumber')
                    ->getQuery()
                    ->getResult();

                // Testcase entities may already have judging_runs hydrated for the current judging,
                // so fetch previous runs separately and index them explicitly.
                /** @var JudgingRun[] $previousRuns */
                $previousRuns = $this->em->getRepository(JudgingRun::class)->findBy([
                    'judging' => $lastJudging,
                ]);
                foreach ($previousRuns as $previousRun) {
                    $lastRunsByTestcaseId[$previousRun->getTestcase()->getTestcaseid()] = $previousRun;
                }
            }
        }

        $unjudgableReasons = [];
        if ($runsOutstanding || $submission->getResult() == null) {
            // Determine if this submission is unjudgable.

            $numActiveJudgehosts = (int)$this->em->createQueryBuilder()
                ->from(Judgehost::class, 'j')
                ->select('count(j.judgehostid)')
                ->andWhere('j.enabled = 1')
                ->getQuery()
                ->getSingleScalarResult();
            if ($numActiveJudgehosts == 0) {
                $extraMsg = '';
                if (!$this->config->get('judgehost_activated_by_default')) {
                    $extraMsg = ' (judgehosts are disabled by default in your configuration)';
                }
                $unjudgableReasons[] = 'No active judgehost. Add or enable judgehosts' . $extraMsg . '!';
            }

            if (!$submission->getLanguage()->getAllowJudge()) {
                $unjudgableReasons[] = 'Submission language is currently not allowed to be judged!';
            }

            if (!$submission->getContestProblem()->getAllowJudge()) {
                $unjudgableReasons[] = 'Problem is currently not allowed to be judged!';
            }
        }

        if (!isset($judging)) {
            $requestedOutputCount = 0;
        } else {
            $requestedOutputCount = (int)$this->em->createQueryBuilder()
                ->from(JudgeTask::class, 'jt')
                ->select('count(jt.judgetaskid)')
                ->andWhere('jt.type = :type')
                ->andWhere('jt.jobid = :judgingid')
                ->andWhere('jt.starttime IS NULL')
                ->setParameter('type', JudgeTaskType::DEBUG_INFO)
                ->setParameter('judgingid', $judging->getJudgingid())
                ->getQuery()
                ->getSingleScalarResult();
        }

        $twigData = [
            'submission' => $submission,
            'previousNext' => $this->getPreviousAndNextObjectIds(
                Submission::class,
                $submission->getExternalid(),
                orderBy: ['e.submittime' => 'ASC', 'e.submitid' => 'ASC'],
                filterOnContest: true,
            ),
            'lastSubmission' => $lastSubmission,
            'judgings' => $judgings,
            'maxRunTimes' => $maxRunTimes,
            'timelimits' => $timelimits,
            'selectedJudging' => $selectedJudging,
            'lastJudging' => $lastJudging,
            'runs' => $runs,
            'runsOutstanding' => $runsOutstanding,
            'judgehosts' => $judgehosts,
            'sameTestcaseHashes' => $sameTestcaseHashes,
            'externalRuns' => $externalRuns,
            'runsOutput' => $runsOutput,
            'lastRuns' => $lastRuns,
            'lastRunsByTestcaseId' => $lastRunsByTestcaseId,
            'unjudgableReasons' => $unjudgableReasons,
            'verificationRequired' => (bool)$this->config->get('verification_required'),
            'claimWarning' => $claimWarning,
            'combinedRunCompare' => $submission->getProblem()->isInteractiveProblem(),
            'requestedOutputCount' => $requestedOutputCount,
            'version_warnings' => [],
            'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(),
            'thumbnailSize' => $this->config->get('thumbnail_size'),
            'isAnalystMode' => $this->config->get('lazy_eval_results') === DOMJudgeService::EVAL_ANALYST,
        ];

        if ($selectedJudging) {
            $twigData['scoringHierarchy'] = $this->submissionService->getScoringHierarchy($submission->getProblem(), $selectedJudging);
        }

        if ($selectedJudging === null) {
            // Automatically refresh page while we wait for judging data.
            $twigData['refresh'] = [
                'after' => 15,
                'url' => $this->generateUrl('jury_submission', [
                    'contestId' => $contestId,
                    'submitId' => $submission->getExternalid(),
                ]),
            ];
        } else {
            $contestProblem = $submission->getContestProblem();
            /** @var JudgeTask[] $judgeTasks */
            $judgeTasks = $this->em->getRepository(JudgeTask::class)->findBy([
                'jobid' => $selectedJudging->getJudgingid(),
                'type' => JudgeTaskType::JUDGING_RUN,
                ]);
            $unique_compiler_versions = [];
            $unique_runner_versions = [];
            $sampleJudgeTask = null;
            foreach ($judgeTasks as $judgeTask) {
                $sampleJudgeTask = $judgeTask;
                $version = $judgeTask->getVersion();
                if (!$version) {
                    continue;
                }
                if ($version->getCompilerVersion()) {
                    $unique_compiler_versions[$version->getCompilerVersion()] = true;
                }
                if ($version->getRunnerVersion()) {
                    $unique_runner_versions[$version->getRunnerVersion()] = true;
                }
            }
            if (count($unique_compiler_versions) > 1) {
                $twigData['version_warnings']['compiler'] = array_keys($unique_compiler_versions);
            }
            if (count($unique_runner_versions) > 1) {
                $twigData['version_warnings']['runner'] = array_keys($unique_runner_versions);
            }
            if ($sampleJudgeTask !== null) {
                $errors = [];
                $this->maybeGetErrors('Compile config',
                    $this->dj->getCompileConfig($submission),
                    $sampleJudgeTask->getCompileConfig(),
                    $errors);
                $this->maybeGetErrors('Run config',
                    $this->dj->getRunConfig($contestProblem, $submission),
                    $sampleJudgeTask->getRunConfig(),
                    $errors);
                // Compare config can vary per testcase group (each group may override
                // outputValidatorFlags), so check each judge task against its group's config.
                $testcaseIds = array_map(fn(JudgeTask $jt) => $jt->getTestcaseId(), $judgeTasks);
                $testcases = $this->em->getRepository(Testcase::class)->findBy(['testcaseid' => $testcaseIds]);
                $testcaseMap = [];
                foreach ($testcases as $tc) {
                    $testcaseMap[$tc->getTestcaseid()] = $tc;
                }
                $checkedGroups = [];
                foreach ($judgeTasks as $judgeTask) {
                    $tc = $testcaseMap[$judgeTask->getTestcaseId()] ?? null;
                    $group = $tc?->getTestcaseGroup();
                    $groupKey = $group?->getTestcaseGroupId() ?? 0;
                    if (isset($checkedGroups[$groupKey])) {
                        continue;
                    }
                    $checkedGroups[$groupKey] = true;
                    if ($group !== null && $group->getOutputValidatorFlags() !== '') {
                        $expectedConfig = $this->dj->getCompareConfig($contestProblem, $group->getOutputValidatorFlags());
                        $label = 'Compare config (group: ' . $group->getName() . ')';
                    } else {
                        $expectedConfig = $this->dj->getCompareConfig($contestProblem);
                        $label = 'Compare config';
                    }
                    $this->maybeGetErrors($label, $expectedConfig, $judgeTask->getCompareConfig(), $errors);
                }
                if (!empty($errors)) {
                    if ($selectedJudging->getValid()) {
                        $type = 'danger';
                        $header = "Some parameters have changed since the judging was created, consider rejudging.\n\n";
                    } else {
                        $type = 'warning';
                        $header = "Some parameters have changed since the judging was created, but this judging has been superseded, please verify if that needs a rejudging.\n\n";
                    }
                    $this->addFlash($type, $header . implode("\n", $errors));
                }
            }
        }

        return $this->render('jury/submission.html.twig', $twigData);
    }

    #[Route(path: '/request-full-debug/{jid}', name: 'request_full_debug')]
    public function requestFullDebug(Request $request, Judging $jid): RedirectResponse
    {
        $submission = $jid->getSubmission();
        $defaultFullDebugExecutable = $this->em
            ->getRepository(Executable::class)
            ->findOneBy(['execid' => $this->config->get('default_full_debug')]);
        if ($defaultFullDebugExecutable === null) {
            $this->addFlash('error', 'No default full_debug executable specified, please configure one.');
        } else {
            $executable = $defaultFullDebugExecutable->getImmutableExecutable();
            foreach ($jid->getJudgehosts() as $hostname) {
                $judgehost = $this->em
                    ->getRepository(Judgehost::class)
                    ->findOneBy(['hostname' => $hostname]);
                $judgeTask = new JudgeTask();
                $judgeTask
                    ->setType(JudgeTaskType::DEBUG_INFO)
                    ->setJudgehost($judgehost)
                    ->setSubmission($submission)
                    ->setPriority(JudgeTask::PRIORITY_HIGH)
                    ->setJobId($jid->getJudgingid())
                    ->setUuid($jid->getUuid())
                    ->setRunScriptId($executable->getImmutableExecId())
                    ->setRunConfig(Utils::jsonEncode(['hash' => $executable->getHash()]));
                $this->em->persist($judgeTask);
            }
            $this->em->flush();
        }
        return $this->redirectToLocalReferrer($this->router, $request, $this->generateUrl('jury_submission', [
            'contestId' => $jid->getSubmission()->getContest()->getExternalid(),
            'submitId' => $jid->getSubmission()->getExternalid(),
            'jid' => $jid->getJudgingid(),
        ]));
    }

    #[Route(path: '/download-full-debug/{debug_package_id}', name: 'download_full_debug')]
    public function downloadFullDebug(
        #[MapEntity(id: 'debug_package_id')] DebugPackage $debugPackage
    ): StreamedResponse {
        $name = 'debug_package.j' . $debugPackage->getJudging()->getJudgingid()
            . '.db' . $debugPackage->getDebugPackageId()
            . '.jh' . ($debugPackage->getJudgehost()?->getJudgehostid() ?? 'unknown')
            . '.tar.gz';
        return Utils::streamAsBinaryFile(file_get_contents($debugPackage->getFilename()), $name);
    }

    #[Route(path: '/request-output/{jid}/{jrid}/{pass}', name: 'request_output')]
    public function requestOutput(Request $request, Judging $jid, JudgingRun $jrid, int $pass = 1): RedirectResponse
    {
        $submission = $jid->getSubmission();
        $testcase = $jrid->getTestcase();
        $judgeTask = new JudgeTask();
        $judgeTask
            ->setType(JudgeTaskType::DEBUG_INFO)
            ->setJudgehost($jrid->getJudgeTask()->getJudgehost())
            ->setSubmission($submission)
            ->setPriority(JudgeTask::PRIORITY_HIGH)
            ->setJobId($jid->getJudgingid())
            ->setUuid($jid->getUuid())
            ->setPass($pass)
            ->setTestcaseId($testcase->getTestcaseid())
            ->setTestcaseHash($testcase->getTestcaseHash());
        $this->em->persist($judgeTask);
        $this->em->flush();
        return $this->redirectToLocalReferrer($this->router, $request, $this->generateUrl('jury_submission', [
            'contestId' => $jid->getSubmission()->getContest()->getExternalid(),
            'submitId' => $jid->getSubmission()->getExternalid(),
            'jid' => $jid->getJudgingid(),
        ]));
    }

    #[Route(path: '/by-judging-id/{jid}', name: 'jury_submission_by_judging')]
    public function viewForJudgingAction(Judging $jid): RedirectResponse
    {
        return $this->redirectToRoute('jury_submission', [
            'contestId' => $jid->getSubmission()->getContest()->getExternalid(),
            'submitId' => $jid->getSubmission()->getExternalid(),
            'jid' => $jid->getJudgingid(),
        ]);
    }

    #[Route(path: '/by-external-judgement-id/{externalJudgement}', name: 'jury_submission_by_external_judgement')]
    public function viewForExternalJudgementAction(ExternalJudgement $externalJudgement): RedirectResponse
    {
        return $this->redirectToRoute('jury_submission', [
            'contestId' => $externalJudgement->getSubmission()->getContest()->getExternalid(),
            'submitId' => $externalJudgement->getSubmission()->getExternalid(),
        ]);
    }

    #[Route(path: '/by-contest-and-external-id/{externalContestId}/{externalId}', name: 'jury_submission_by_context_external_id')]
    public function viewForContestExternalIdAction(string $externalContestId, string $externalId): Response
    {
        $contest = $this->em->getRepository(Contest::class)->findOneBy(['externalid' => $externalContestId]);
        if ($contest === null) {
            throw new NotFoundHttpException(sprintf('No contest found with external ID %s', $externalContestId));
        }

        $submission = $this->em->getRepository(Submission::class)
            ->findOneBy([
                'contest' => $contest,
                'externalid' => $externalId
            ]);

        if (!$submission) {
            throw new NotFoundHttpException(sprintf('No submission found with external ID %s', $externalId));
        }

        return $this->redirectToRoute('jury_submission', [
            'contestId' => $contest->getExternalid(),
            'submitId' => $submission->getExternalid(),
        ]);
    }

    #[Route(path: '/contests/{contestId}/submissions/{submission}/runs/{run}/team-output', name: 'jury_submission_team_output')]
    public function teamOutputAction(
        string $contestId,
        #[MapEntity(mapping: ['submission' => 'externalid'])]
        Submission $submission,
        JudgingRun $run,
    ): StreamedResponse {
        $contest = $this->dj->getContestByExternalId($contestId);
        if ($run->getJudging()->getSubmission()->getSubmitid() !== $submission->getSubmitid() || $submission->getContest()->getCid() !== $contest->getCid()) {
            throw new BadRequestHttpException('Integrity problem while fetching team output.');
        }
        if ($run->getOutput() === null) {
            throw new NotFoundHttpException('No team output available (yet).');
        }

        $filename = sprintf('%s.t%d.%s.run%d.%s.out', $submission->getProblem()->getExternalid(), $run->getTestcase()->getRank(),
                            $submission->getContestProblem()->getShortname(), $run->getRunid(),
                            $submission->getTeam()->getExternalid());

        $outputRun = $run->getOutput()->getOutputRun();
        return Utils::streamAsBinaryFile($outputRun, $filename);
    }

    private function allowEdit(): bool {
        return $this->authService->getUser()->getTeam() && $this->authService->checkRole('team');
    }

    /**
     * @throws NonUniqueResultException
     */
    #[Route(path: '/contests/{contestId}/submissions/{submission}/source', name: 'jury_submission_source')]
    public function sourceAction(
        string $contestId,
        #[MapEntity(mapping: ['submission' => 'externalid'])]
        Submission $submission,
        #[MapQueryParameter]
        ?int $fetch = null
    ): Response {
        $contest = $this->dj->getContestByExternalId($contestId);
        if ($submission->getContest()->getCid() !== $contest->getCid()) {
            throw new NotFoundHttpException(sprintf('Submission %s not found in contest %s', $submission->getExternalid(), $contestId));
        }
        if ($fetch !== null) {
            /** @var SubmissionFile|null $file */
            $file = $this->em->createQueryBuilder()
                ->from(SubmissionFile::class, 'file')
                ->select('file')
                ->andWhere('file.ranknumber = :ranknumber')
                ->andWhere('file.submission = :submission')
                ->setParameter('ranknumber', $fetch)
                ->setParameter('submission', $submission)
                ->getQuery()
                ->getOneOrNullResult();
            if (!$file) {
                throw new NotFoundHttpException(sprintf('No submission file found with rank %s',
                                                        $fetch));
            }
            // Download requested
            $response = new Response();
            $response->headers->set('Content-Type',
                                    sprintf('text/plain; name="%s"; charset="utf-8"', $file->getFilename()));
            $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $file->getFilename()));
            $response->headers->set('Content-Length', (string)strlen($file->getSourcecode()));
            $response->setContent($file->getSourcecode());

            return $response;
        }

        /** @var array{
         *      submitid: string,
         *      tag?: string
         * } $otherSubmissions
         */
        $otherSubmissions = [];
        $originalSubmission = $submission->getOriginalSubmission();
        if ($originalSubmission) {
            $otherSubmissions[] = [
                'submitid' => $originalSubmission->getExternalid(),
                'tag'      => 'original',
            ];
            /** @var Submission $oldSubmission */
            $oldSubmission = $this->em->createQueryBuilder()
                ->from(Submission::class, 's')
                ->select('s')
                ->andWhere('s.problem = :probid')
                ->andWhere('s.language = :langid')
                ->andWhere('s.submittime < :submittime')
                ->andWhere('s.originalSubmission = :origsubmitid')
                ->setParameter('probid', $submission->getProblem())
                ->setParameter('langid', $submission->getLanguage())
                ->setParameter('submittime', $submission->getSubmittime())
                ->setParameter('origsubmitid', $submission->getOriginalSubmission())
                ->orderBy('s.submittime', 'DESC')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();
        } else {
            $oldSubmission = $this->em->createQueryBuilder()
                ->from(Submission::class, 's')
                ->select('s')
                ->andWhere('s.team = :teamid')
                ->andWhere('s.problem = :probid')
                ->andWhere('s.language = :langid')
                ->andWhere('s.submittime < :submittime')
                ->andWhere('s.contest = :contest')
                ->setParameter('teamid', $submission->getTeam())
                ->setParameter('probid', $submission->getProblem())
                ->setParameter('langid', $submission->getLanguage())
                ->setParameter('submittime', $submission->getSubmittime())
                ->setParameter('contest', $submission->getContest())
                ->orderBy('s.submittime', 'DESC')
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();
        }
        if ($oldSubmission !== null) {
            $otherSubmissions[] = [
                'submitid' => $oldSubmission->getExternalid(),
                'tag'      => 'previous',
            ];
        }

        $files_query = array_map(fn($s) => $s['submitid'], $otherSubmissions);
        $files_query[] = $submission->getExternalid();
        /** @var SubmissionFile[] $oldFiles */
        $oldFiles = $this->em->createQueryBuilder()
            ->from(SubmissionFile::class, 'file')
            ->select('file')
            ->join('file.submission', 's')
            ->andWhere('s.externalid in (:submissions)')
            ->andWhere('s.contest = :contest')
            ->setParameter('submissions', $files_query)
            ->setParameter('contest', $contest)
            ->orderBy('s.externalid, file.ranknumber')
            ->getQuery()
            ->getResult();

        /** @var array<string, array<int, array{
         *      rank: int,
         *      filename: string,
         *      source: string,
         *      renamedFrom?: string
         * }>> $files */
        $files = [];
        /** @var array<int, (string|false)> $renames */
        $renames = [];
        foreach ($oldFiles as $f) {
            $submitId = $f->getSubmission()->getExternalid();
            $files[$f->getFilename()] ??= [];
            $files[$f->getFilename()][$submitId] = [
                'rank' => $f->getRank(),
                'filename' => $f->getFilename(),
                'source'   => Utils::reencodeUtf8($f->getSourcecode()),
            ];

            // Keep track of the single filename within a submission for handling renaming.
            $renames[$submitId] = array_key_exists($submitId, $renames) ? false : $f->getFilename();
        }

        // Handle file renaming for a single-file submission.
        $renamedTo = $renames[$submission->getExternalid()];
        if ($renamedTo !== false) {
            foreach ($renames as $submitId => $filename) {
                if ($filename !== false && $filename !== $renamedTo) {
                    $files[$renamedTo][$submitId] = $files[$filename][$submitId];
                    $files[$renamedTo][$submitId]['renamedFrom'] = $filename;
                    unset($files[$filename][$submitId]);
                    if (count($files[$filename]) === 0) {
                        unset($files[$filename]);
                    }
                }
            }
        }

        ksort($files);
        return $this->render('jury/submission_source.html.twig', [
            'submission' => $submission,
            'files' => $files,
            'oldSubmission' => $oldSubmission,
            'originalSubmission' => $originalSubmission,
            'allowEdit' => $this->allowEdit(),
            'otherSubmissions' => $otherSubmissions,
            'fill_height' => true,
        ]);
    }

    #[Route(path: '/contests/{contestId}/submissions/{submission}/edit-source', name: 'jury_submission_edit_source')]
    public function editSourceAction(
        Request $request,
        string $contestId,
        #[MapEntity(mapping: ['submission' => 'externalid'])]
        Submission $submission,
        #[MapQueryParameter]
        ?int $rank = null
    ): Response {
        $contest = $this->dj->getContestByExternalId($contestId);
        if ($submission->getContest()->getCid() !== $contest->getCid()) {
            throw new NotFoundHttpException(sprintf('Submission %s not found in contest %s', $submission->getExternalid(), $contestId));
        }

        if (!$this->allowEdit()) {
            $this->addFlash('danger', 'You cannot re-submit code without being a team.');
            return $this->redirectToLocalReferrer($this->router, $request, $this->generateUrl(
                'jury_submission',
                ['contestId' => $contestId, 'submitId' => $submission->getExternalid()]
            ));
        }

        /** @var SubmissionFile[] $files */
        $files = $this->em->createQueryBuilder()
            ->from(SubmissionFile::class, 'file')
            ->select('file')
            ->andWhere('file.submission = :submission')
            ->setParameter('submission', $submission)
            ->orderBy('file.ranknumber')
            ->getQuery()
            ->getResult();

        $data = [
            'problem' => $submission->getProblem(),
            'language' => $submission->getLanguage(),
            'entry_point' => $submission->getEntryPoint(),
        ];

        foreach ($files as $file) {
            $data['source' . $file->getRank()] = $file->getSourcecode();
        }

        $formBuilder = $this->createFormBuilder($data)
            ->add('problem', EntityType::class, [
                'class' => Problem::class,
                'choice_label' => 'name',
                'choice_value' => 'externalid',
                'query_builder' => fn(EntityRepository $er) => $er->createQueryBuilder('p')
                    ->join('p.contest_problems', 'cp')
                    ->andWhere('cp.allowSubmit = 1')
                    ->andWhere('cp.contest = :contest')
                    ->setParameter('contest', $submission->getContest())
                    ->orderBy('p.name'),
            ])
            ->add('language', EntityType::class, [
                'class' => Language::class,
                'choice_label' => 'name',
                'choice_value' => 'externalid',
                'query_builder' => fn(EntityRepository $er) => $er->createQueryBuilder('lang')
                    ->andWhere('lang.allowSubmit = 1')
                    ->orderBy('lang.name')
            ])
            ->add('entry_point', TextType::class, [
                'label' => 'Optional entry point',
                'required' => false,
            ]);

        foreach ($files as $file) {
            $formBuilder->add('source' . $file->getRank(), TextareaType::class);
        }

        $form = $formBuilder->getForm();

        // Handle the form if it is submitted
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $submittedData = $form->getData();

            /** @var UploadedFile[] $filesToSubmit */
            $filesToSubmit = [];
            $tmpdir        = $this->dj->getDomjudgeTmpDir();
            foreach ($files as $file) {
                if (!($tmpfname = tempnam($tmpdir, "edit_source-"))) {
                    throw new ServiceUnavailableHttpException(null, "Could not create temporary file.");
                }
                file_put_contents($tmpfname, $submittedData['source' . $file->getRank()]);
                $filesToSubmit[] = new UploadedFile($tmpfname, $file->getFilename(), null, null, true);
            }

            $team = $this->authService->getUser()->getTeam();
            /** @var Language $language */
            $language   = $submittedData['language'];
            $entryPoint = $submittedData['entry_point'];
            if ($language->getRequireEntryPoint() && $entryPoint === null) {
                $entryPoint = '__auto__';
            }
            $submittedSubmission = $this->submissionService->submitSolution(
                $team,
                $this->authService->getUser(),
                $submittedData['problem'],
                $submission->getContest(),
                $language,
                $filesToSubmit,
                SubmissionSource::EDIT_RESUBMIT,
                $this->getUser()->getUserIdentifier(),
                $submission->getOriginalSubmission() ?? $submission,
                $entryPoint,
                null,
                null,
                $message
            );

            foreach ($filesToSubmit as $file) {
                unlink($file->getRealPath());
            }

            if (!$submittedSubmission) {
                $this->addFlash('danger', $message);
                return $this->redirectToRoute('jury_submission', ['contestId' => $contestId, 'submitId' => $submission->getExternalid()]);
            }

            return $this->redirectToRoute('jury_submission', ['contestId' => $contestId, 'submitId' => $submittedSubmission->getExternalid()]);
        }

        $twigData = [
            'submission' => $submission,
            'files' => $files,
            'form' => $form,
            'selected' => $rank,
            'fill_height' => true,
        ];
        if ($request->isXmlHttpRequest()) {
            return $this->render('jury/submission_edit_source_modal.html.twig', $twigData);
        } else {
            return $this->render('jury/submission_edit_source.html.twig', $twigData);
        }
    }

    #[Route(path: '/{judgingId<\d+>}/request-remaining', name: 'jury_submission_request_remaining', methods: ['POST'])]
    public function requestRemainingRuns(Request $request, int $judgingId): RedirectResponse
    {
        $judging = $this->em->getRepository(Judging::class)->find($judgingId);
        if ($judging === null) {
            throw new BadRequestHttpException("Unknown judging with '$judgingId' requested.");
        }
        $this->judgeRemainingJudgings([$judging]);

        return $this->redirectToLocalReferrer($this->router, $request,
            $this->generateUrl('jury_submission_by_judging', ['jid' => $judgingId])
        );
    }

    /**
     * @throws DBALException
     */
    #[IsGranted('ROLE_ADMIN')]
    #[Route(path: '/contests/{contestId}/submissions/{submitId}/update-status', name: 'jury_submission_update_status', methods: ['POST'])]
    public function updateStatusAction(
        EventLogService $eventLogService,
        ScoreboardService $scoreboardService,
        Request $request,
        string $contestId,
        string $submitId
    ): RedirectResponse {
        $contest = $this->dj->getContestByExternalId($contestId);
        $submission = $this->em->getRepository(Submission::class)->findOneBy([
            'externalid' => $submitId,
            'contest' => $contest,
        ]);
        if (!$submission) {
            throw new NotFoundHttpException(sprintf('Submission %s not found in contest %s', $submitId, $contestId));
        }

        $valid      = $request->request->getBoolean('valid');
        $submission->setValid($valid);
        $this->em->flush();

        $teamId    = $submission->getTeam()->getTeamid();
        $problemId = $submission->getProblem()->getProbid();

        // KLUDGE: We can't log an "undelete", so we re-"create".
        // FIXME: We should also delete/recreate any dependent judging(runs).
        $eventLogService->log('submission', $submission->getSubmitid(), ($valid ? 'create' : 'delete'),
                              $contest->getCid(), null, null, $valid);
        $this->dj->auditlog('submission', $submission->getExternalid(),
                                         'marked ' . ($valid ? 'valid' : 'invalid'));
        $team    = $this->em->getRepository(Team::class)->find($teamId);
        $problem = $this->em->getRepository(Problem::class)->find($problemId);
        $scoreboardService->calculateScoreRow($contest, $team, $problem);

        return $this->redirectToLocalReferrer($this->router, $request,
            $this->generateUrl('jury_submission', ['contestId' => $contestId, 'submitId' => $submission->getExternalid()])
        );
    }

    /**
     * @throws DBALException
     * @throws NoResultException
     * @throws NonUniqueResultException
     * @throws ORMException
     */
    #[Route(path: '/{judgingId<\d+>}/verify', name: 'jury_judging_verify', methods: ['POST'])]
    public function verifyAction(
        EventLogService $eventLogService,
        ScoreboardService $scoreboardService,
        BalloonService $balloonService,
        Request $request,
        int $judgingId
    ): RedirectResponse {
        $this->em->wrapInTransaction(function () use ($eventLogService, $request, $judgingId): void {
            /** @var Judging $judging */
            $judging  = $this->em->getRepository(Judging::class)->find($judgingId);
            $verified = $request->request->getBoolean('verified');
            $comment  = $request->request->get('comment');
            $judging
                ->setVerified($verified)
                ->setJuryMember($verified ? $this->authService->getUser()->getUserIdentifier() : null)
                ->setVerifyComment($comment);

            $this->em->flush();
            $this->dj->auditlog('judging', (string)$judging->getJudgingid(),
                                             $verified ? 'set verified' : 'set unverified');

            if ((bool)$this->config->get('verification_required')) {
                // Log to event table (case of no verification required is handled
                // in the REST API JudgehostController::addJudgingRunAction).
                $eventLogService->log('judging', $judging->getJudgingid(), 'update', $judging->getContest()->getCid());
            }
        });

        $this->em->clear();
        /** @var Judging $judging */
        $judging = $this->em->getRepository(Judging::class)->find($judgingId);
        if ((bool)$this->config->get('verification_required')) {
            // We need to update the score for all teams for this problem, since the first to solve might now
            // have changed.
            $teamsQueryBuilder = $this->em->createQueryBuilder()
                                     ->from(Team::class, 't')
                                     ->select('t')
                                     ->orderBy('t.teamid');
            if (!$judging->getContest()->isOpenToAllTeams()) {
                $teamsQueryBuilder
                    ->leftJoin('t.contests', 'c')
                    ->join('t.categories', 'cat')
                    ->leftJoin('cat.contests', 'cc')
                    ->andWhere('c.cid = :cid OR cc.cid = :cid')
                    ->setParameter('cid', $judging->getContest()->getCid());
            }
            /** @var Team[] $teams */
            $teams = $teamsQueryBuilder->getQuery()->getResult();
            foreach ($teams as $team) {
                $scoreboardService->calculateScoreRow($judging->getContest(), $team, $judging->getSubmission()->getProblem());
            }
            $balloonService->updateBalloons($judging->getContest(), $judging->getSubmission(), $judging);
        }

        // Redirect to local referrer page but fall back to same defaults
        if ($request->request->getBoolean('verified')) {
            $this->addFlash('info', "Verified judging j$judgingId");
            $redirect = $this->generateUrl('jury_submissions', ['contestId' => $judging->getContest()->getExternalid()]);
        } else {
            $this->addFlash('info', "Unmarked judging j$judgingId as verified");
            $redirect = $this->generateUrl('jury_submission_by_judging', ['jid' => $judgingId]);
        }

        return $this->redirectToLocalReferrer($this->router, $request, $redirect);
    }


    #[Route(path: '/shadow-difference/{extjudgementid<\d+>}/verify', name: 'jury_shadow_difference_verify', methods: ['POST'])]
    public function verifyShadowDifferenceAction(
        Request $request,
        int $extjudgementid
    ): RedirectResponse {
        /** @var ExternalJudgement $judgement */
        $judgement  = $this->em->getRepository(ExternalJudgement::class)->find($extjudgementid);
        $this->em->wrapInTransaction(function () use ($request, $judgement): void {
            $verified = $request->request->getBoolean('verified');
            $comment  = $request->request->get('comment');
            $judgement
                ->setVerified($verified)
                ->setJuryMember($verified ? $this->authService->getUser()->getUserIdentifier() : null)
                ->setVerifyComment($comment);

            $this->em->flush();
            $this->dj->auditlog('external_judgement', (string)$judgement->getExtjudgementid(),
                $verified ? 'set verified' : 'set unverified');
        });

        // Redirect to local referrer page but fall back to same defaults
        if ($request->request->getBoolean('verified')) {
            $redirect = $this->generateUrl('jury_shadow_differences');
        } else {
            $redirect = $this->generateUrl('jury_submission_by_external_judgement', ['externalJudgement' => $extjudgementid]);
        }

        return $this->redirectToLocalReferrer($this->router, $request, $redirect);
    }

    protected function processClaim(
        Judging|ExternalJudgement|null $judging,
        Request $request,
        ?string &$claimWarning
    ): ?RedirectResponse {
        $user   = $this->authService->getUser();
        $action = ($request->get('claim') || $request->get('claimdiff')) ? 'claim' : 'unclaim';

        $type = ($judging instanceof ExternalJudgement) ?'shadow difference' : 'submission';

        if ($judging === null) {
            $claimWarning = sprintf('Cannot %s this %s: no valid judging found.', $action, $type);
        } elseif ($judging->getVerified()) {
            $claimWarning = sprintf('Cannot %s this %s: judging already verified.', $action, $type);
        } elseif (!$user && $action === 'claim') {
            $claimWarning = sprintf('Cannot claim this %s: no jury member specified.', $type);
        } else {
            if (!empty($judging->getJuryMember()) && $action === 'claim' &&
                $user->getUsername() !== $judging->getJuryMember() &&
                !$request->request->has('forceclaim')) {
                $claimWarning = sprintf('%s has been claimed by %s. Claim again on this page to force an update.',
                    ucfirst($type), $judging->getJuryMember());
            } else {
                $judging->setJuryMember($action === 'claim' ? $user->getUsername() : null);
                $this->em->flush();
                if ($judging instanceof ExternalJudgement) {
                    $auditLogType = 'external_judgement';
                    $auditLogId = $judging->getExtjudgementid();
                } else {
                    $auditLogType = 'judging';
                    $auditLogId = $judging->getJudgingid();
                }
                $this->dj->auditlog($auditLogType, (string)$auditLogId, $action . 'ed');

                if ($action === 'claim') {
                    return $this->redirectToRoute('jury_submission', [
                        'contestId' => $judging->getSubmission()->getContest()->getExternalid(),
                        'submitId' => $judging->getSubmission()->getExternalid(),
                    ]);
                } else {
                    return $this->redirectToLocalReferrer($this->router, $request,
                        $this->generateUrl('jury_submissions', ['contestId' => $judging->getSubmission()->getContest()->getExternalid()])
                    );
                }
            }
        }

        return null;
    }

    #[Route(path: '/contests/{contestId}/submissions/{submitId}/create-tasks', name: 'jury_submission_create_tasks')]
    public function createJudgeTasks(string $contestId, string $submitId): RedirectResponse
    {
        $contest = $this->dj->getContestByExternalId($contestId);
        $submission = $this->em->getRepository(Submission::class)->findOneBy([
            'externalid' => $submitId,
            'contest' => $contest,
        ]);
        if (!$submission) {
            throw new NotFoundHttpException(sprintf('Submission %s not found in contest %s', $submitId, $contestId));
        }

        $this->dj->unblockJudgeTasksForSubmission((string)$submission->getSubmitid());
        $this->addFlash('info', "Started judging for submission: $submitId");
        return $this->redirectToRoute('jury_submission', ['contestId' => $contestId, 'submitId' => $submitId]);
    }

    /**
     * @param string[] $allErrors
     */
    private function maybeGetErrors(string $type, string $expectedConfigString, string $observedConfigString, array &$allErrors): void
    {
        $expectedConfig = Utils::jsonDecode($expectedConfigString);
        $observedConfig = Utils::jsonDecode($observedConfigString);
        $errors = [];
        foreach (array_keys($expectedConfig) as $k) {
            if (!array_key_exists($k, $observedConfig)) {
                $errors[] = '- ' . preg_replace('/_/', ' ', $k) . ': missing';
            } elseif ($expectedConfig[$k] != $observedConfig[$k]) {
                if ($k === 'hash') {
                    $errors[] = '- script has changed';
                } elseif ($k === 'entry_point') {
                    // Changes to the entry point can only happen for jury submissions during initial problem upload.
                    // Silently ignore.
                } else {
                    $errors[] = '- ' . preg_replace('/_/', ' ', $k) . ': '
                        . Utils::jsonEncode($observedConfig[$k]) . ' → ' . Utils::jsonEncode($expectedConfig[$k]);
                }
            }
        }
        foreach (array_keys($observedConfig) as $k) {
            if (!array_key_exists($k, $expectedConfig)) {
                $errors[] = '- ' . preg_replace('/_/', ' ', $k) . ': unexpected';
            }
        }
        if (!empty($errors)) {
            $allErrors[] = $type . ' changes:';
            array_push($allErrors, ...$errors);
        }
    }
}
