<?php

namespace App\Services;

use App\Models\SchoolYearClosingBatchModel;
use App\Models\SchoolYearClosingItemModel;
use App\Models\SchoolYearModel;
use App\Models\ConfigurationModel;
use App\Support\SchoolYear\SchoolYearStatus;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
use RuntimeException;

final class SchoolYearClosingService
{
    public function __construct(
        private readonly SchoolYearModel $schoolYearModel,
        private readonly SchoolYearClosingBatchModel $batchModel,
        private readonly SchoolYearClosingItemModel $itemModel,
        private readonly ConfigurationModel $configurationModel,
        private readonly SchoolYearManagementService $managementService,
        private readonly BaseConnection $db,
    ) {
    }

    public function preview(int $sourceYearId, ?int $targetYearId = null): array
    {
        $source = $this->requireYear($sourceYearId);
        $target = $targetYearId !== null ? $this->schoolYearModel->find($targetYearId) : $this->nextDraftYear((string) $source['name']);
        $sourceName = (string) $source['name'];

        $finance = $this->financialSummary($sourceName);
        $overview = [
            'students' => $this->countBySchoolYear('student_class', $sourceName, 'student_id'),
            'families' => $this->countFamilies($sourceName),
            'classes' => $this->countBySchoolYear('classSection', $sourceName, 'class_id'),
            'teachers' => $this->countBySchoolYear('teacher_class', $sourceName, 'teacher_id'),
            'invoices' => $finance['invoice_count'],
            'total_invoiced' => $finance['total_invoiced'],
            'total_paid' => $finance['total_paid'],
            'total_outstanding' => $finance['total_outstanding'],
        ];

        $findings = [];
        if ($target === null) {
            $findings[] = $this->finding('blocking', 'Missing target year', 'Create a draft target school year before starting closing.');
        } elseif (! in_array((string) $target['status'], [SchoolYearStatus::DRAFT, SchoolYearStatus::ACTIVE], true)) {
            $findings[] = $this->finding('blocking', 'Invalid target year', 'The target school year must be draft or active.');
        }

        foreach ($this->unpaidInvoiceFindings($sourceName) as $finding) {
            $findings[] = $finding;
        }

        $promotion = $this->promotionPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : null);
        if (($promotion['summary']['missing_decision'] ?? 0) > 0) {
            $findings[] = $this->finding(
                'blocking',
                'Students missing promotion decisions',
                $promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.'
            );
        }
        if (($promotion['summary']['pending_decision'] ?? 0) > 0) {
            $findings[] = $this->finding(
                'blocking',
                'Students with pending promotion decisions',
                $promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.'
            );
        }
        if (($promotion['summary']['missing_queue'] ?? 0) > 0) {
            $findings[] = $this->finding(
                'warning',
                'Passed students missing promotion queue',
                $promotion['summary']['missing_queue'] . ' passed student(s) are not queued for the target school year. This will not block closing.'
            );
        }

        if ($this->countMissingSchoolYearRows('invoices') > 0) {
            $findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
        }

        $carryForward = $this->carryForwardFamilies($sourceName);
        $warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning'));
        $blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking'));

        $result = [
            'source' => $source,
            'target' => $target,
            'overview' => $overview,
            'finance' => $finance,
            'promotion' => $promotion,
            'findings' => $findings,
            'blockers' => $blockers,
            'warnings' => $warnings,
            'carry_forward' => $carryForward,
            'generated_at' => date('Y-m-d H:i:s'),
        ];
        $result['hash'] = $this->hashPreview($result);

        return $result;
    }

    public function start(int $sourceYearId, int $targetYearId, ?int $userId = null): void
    {
        $this->assertClosingTablesExist();

        $source = $this->requireYear($sourceYearId);

        if (($source['status'] ?? '') !== SchoolYearStatus::ACTIVE) {
            throw new InvalidArgumentException('Only an active school year can begin closing.');
        }

        $preview = $this->preview($sourceYearId, $targetYearId);
        if ($preview['blockers'] !== []) {
            throw new InvalidArgumentException('Resolve blocking closing issues before starting closing.');
        }

        $this->db->transStart();
        $now = date('Y-m-d H:i:s');
        $batchId = $this->batchModel->insert([
            'source_school_year_id' => $sourceYearId,
            'target_school_year_id' => $targetYearId,
            'status' => 'started',
            'preview_hash' => $preview['hash'],
            'total_families' => count($preview['carry_forward']),
            'total_positive_balance' => $preview['finance']['positive_balance'],
            'total_credit_balance' => $preview['finance']['credit_balance'],
            'started_by' => $userId,
            'started_at' => $now,
        ], true);

        foreach ($preview['carry_forward'] as $row) {
            $this->itemModel->insert([
                'closing_batch_id' => $batchId,
                'family_id' => (int) $row['family_id'],
                'source_balance' => $row['source_balance'],
                'credit_amount' => $row['credit_amount'],
                'carry_forward_amount' => $row['carry_forward_amount'],
                'status' => 'pending',
            ]);
        }

        $this->schoolYearModel->update($sourceYearId, [
            'status' => SchoolYearStatus::CLOSING,
            'closing_started_at' => $now,
            'updated_by' => $userId,
        ]);
        $this->managementService->log($sourceYearId, SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'closing_start', $userId, [
            'target_school_year_id' => $targetYearId,
            'closing_batch_id' => $batchId,
            'preview_hash' => $preview['hash'],
        ]);
        $this->db->transComplete();

        if ($this->db->transStatus() === false) {
            throw new RuntimeException('Unable to start school year closing.');
        }
    }

    public function execute(int $sourceYearId, ?int $userId = null): void
    {
        $this->assertClosingTablesExist();

        $batch = $this->latestOpenBatch($sourceYearId);
        if ($batch === null) {
            throw new InvalidArgumentException('No open closing batch exists.');
        }

        $preview = $this->preview($sourceYearId, (int) $batch['target_school_year_id']);
        if ($preview['hash'] !== (string) $batch['preview_hash']) {
            throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.');
        }

        $this->db->transStart();
        $items = $this->itemModel->where('closing_batch_id', (int) $batch['id'])->findAll();
        foreach ($items as $item) {
            if (($item['status'] ?? '') === 'completed') {
                continue;
            }

            $this->itemModel->update((int) $item['id'], ['status' => 'completed']);
        }
        $this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
        $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSING, 'carry_forward_execute', $userId, [
            'closing_batch_id' => (int) $batch['id'],
            'note' => 'Marked previewed carry-forward items complete. Target accounting records require the dedicated opening-balance schema.',
        ]);
        $this->db->transComplete();

        if ($this->db->transStatus() === false) {
            throw new RuntimeException('Unable to execute carry-forward.');
        }
    }

    public function complete(int $sourceYearId, ?int $userId = null): void
    {
        $this->assertClosingTablesExist();

        $batch = $this->latestOpenBatch($sourceYearId);
        if ($batch === null || ($batch['status'] ?? '') !== 'executed') {
            throw new InvalidArgumentException('Carry-forward must be executed before completing closing.');
        }

        $pending = $this->itemModel
            ->where('closing_batch_id', (int) $batch['id'])
            ->where('status !=', 'completed')
            ->countAllResults();
        if ($pending > 0) {
            throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.');
        }

        $targetYearId = (int) ($batch['target_school_year_id'] ?? 0);
        $target = $targetYearId > 0 ? $this->schoolYearModel->find($targetYearId) : null;
        if ($target === null) {
            throw new InvalidArgumentException('A target school year is required before completing closing.');
        }

        $this->db->transStart();
        $now = date('Y-m-d H:i:s');
        $this->batchModel->update((int) $batch['id'], [
            'status' => 'completed',
            'completed_by' => $userId,
            'completed_at' => $now,
        ]);
        $this->schoolYearModel->update($sourceYearId, [
            'status' => SchoolYearStatus::CLOSED,
            'closed_at' => $now,
            'updated_by' => $userId,
        ]);
        $this->schoolYearModel->update($targetYearId, [
            'status' => SchoolYearStatus::ACTIVE,
            'previous_school_year_id' => $sourceYearId,
            'activated_at' => $target['activated_at'] ?? $now,
            'updated_by' => $userId,
        ]);
        $this->schoolYearModel->update($sourceYearId, [
            'next_school_year_id' => $targetYearId,
        ]);
        $targetName = (string) ($target['name'] ?? '');
        $this->configurationModel->setConfigValueByKey('school_year', $targetName);
        $this->syncActiveYearSession($targetName);
        $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [
            'closing_batch_id' => (int) $batch['id'],
            'activated_school_year_id' => $targetYearId,
        ]);
        $this->managementService->log($targetYearId, (string) ($target['status'] ?? SchoolYearStatus::DRAFT), SchoolYearStatus::ACTIVE, 'activate_after_closing', $userId, [
            'closed_school_year_id' => $sourceYearId,
            'closing_batch_id' => (int) $batch['id'],
        ]);
        $this->db->transComplete();

        if ($this->db->transStatus() === false) {
            throw new RuntimeException('Unable to complete closing.');
        }
    }

    public function cancel(int $sourceYearId, ?int $userId = null): void
    {
        $this->assertClosingTablesExist();

        $batch = $this->latestOpenBatch($sourceYearId);
        if ($batch !== null && in_array((string) $batch['status'], ['executed', 'completed'], true)) {
            throw new InvalidArgumentException('Closing cannot be cancelled after carry-forward has executed.');
        }

        $this->db->transStart();
        if ($batch !== null) {
            $this->batchModel->update((int) $batch['id'], ['status' => 'cancelled']);
        }
        $this->schoolYearModel->update($sourceYearId, [
            'status' => SchoolYearStatus::ACTIVE,
            'updated_by' => $userId,
        ]);
        $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::ACTIVE, 'closing_cancel', $userId, [
            'closing_batch_id' => $batch['id'] ?? null,
        ]);
        $this->db->transComplete();

        if ($this->db->transStatus() === false) {
            throw new RuntimeException('Unable to cancel closing.');
        }
    }

    public function latestBatch(int $sourceYearId): ?array
    {
        if (! $this->db->tableExists('school_year_closing_batches')) {
            return null;
        }

        return $this->batchModel
            ->where('source_school_year_id', $sourceYearId)
            ->orderBy('id', 'DESC')
            ->first();
    }

    private function requireYear(int $id): array
    {
        $year = $this->schoolYearModel->find($id);
        if ($year === null) {
            throw new InvalidArgumentException('School year not found.');
        }

        return $year;
    }

    private function nextDraftYear(string $sourceName): ?array
    {
        if (! preg_match('/^(\d{4})-(\d{4})$/', $sourceName, $matches)) {
            return null;
        }

        $nextName = $matches[2] . '-' . ((int) $matches[2] + 1);

        return $this->schoolYearModel
            ->where('name', $nextName)
            ->first();
    }

    private function financialSummary(string $schoolYear): array
    {
        $summary = [
            'invoice_count' => 0,
            'total_invoiced' => 0.0,
            'total_paid' => 0.0,
            'total_outstanding' => 0.0,
            'positive_balance' => 0.0,
            'credit_balance' => 0.0,
        ];

        if (! $this->db->tableExists('invoices')) {
            return $summary;
        }

        $row = $this->db->table('invoices')
            ->select('COUNT(*) AS invoice_count')
            ->select('COALESCE(SUM(total_amount), 0) AS total_invoiced')
            ->select('COALESCE(SUM(paid_amount), 0) AS total_paid')
            ->select('COALESCE(SUM(balance), 0) AS total_outstanding')
            ->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false)
            ->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false)
            ->where('school_year', $schoolYear)
            ->get()
            ->getRowArray() ?? [];

        foreach ($summary as $key => $value) {
            $summary[$key] = $key === 'invoice_count' ? (int) ($row[$key] ?? 0) : round((float) ($row[$key] ?? 0), 2);
        }

        return $summary;
    }

    private function unpaidInvoiceFindings(string $schoolYear): array
    {
        if (! $this->db->tableExists('invoices')) {
            return [];
        }

        $count = $this->db->table('invoices')
            ->where('school_year', $schoolYear)
            ->where('balance >', 0)
            ->countAllResults();

        return $count > 0
            ? [$this->finding('warning', 'Outstanding balances exist', "{$count} invoice(s) still have a positive balance and will require carry-forward review.")]
            : [];
    }

    private function carryForwardFamilies(string $schoolYear): array
    {
        if (! $this->db->tableExists('invoices')) {
            return [];
        }

        $builder = $this->db->table('invoices i')
            ->select('i.parent_id AS family_id')
            ->select('COALESCE(SUM(i.balance), 0) AS source_balance')
            ->where('i.school_year', $schoolYear);

        if ($this->db->tableExists('users')) {
            $builder
                ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email')
                ->join('users u', 'u.id = i.parent_id', 'left')
                ->groupBy('i.parent_id, u.firstname, u.lastname, u.email');
        } else {
            $builder->groupBy('i.parent_id');
        }

        $rows = $builder
            ->having('source_balance !=', 0)
            ->orderBy('i.parent_id', 'ASC')
            ->get()
            ->getResultArray();

        return array_map(static function (array $row): array {
            $balance = round((float) $row['source_balance'], 2);

            return [
                'family_id' => (int) $row['family_id'],
                'family' => 'Family #' . (int) $row['family_id'],
                'parent' => trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) $row['family_id'],
                'parent_email' => (string) ($row['parent_email'] ?? ''),
                'source_balance' => $balance,
                'credit_amount' => $balance < 0 ? abs($balance) : 0.0,
                'adjustment_amount' => 0.0,
                'carry_forward_amount' => $balance,
            ];
        }, $rows);
    }

    private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array
    {
        $summary = [
            'total_students' => 0,
            'with_decision' => 0,
            'missing_decision' => 0,
            'pending_decision' => 0,
            'pass' => 0,
            'other_decision' => 0,
            'queued' => 0,
            'assigned' => 0,
            'applied' => 0,
            'missing_queue' => 0,
        ];

        if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('students')) {
            return [
                'summary' => $summary,
                'rows' => [],
            ];
        }

        $hasEventOnly = $this->db->fieldExists('is_event_only', 'student_class');
        $hasActive = $this->db->fieldExists('is_active', 'students');
        $hasDob = $this->db->fieldExists('dob', 'students');
        $hasRegistrationGrade = $this->db->fieldExists('registration_grade', 'students');

        $builder = $this->db->table('student_class sc')
            ->select('sc.student_id, sc.class_section_id, sc.created_at, sc.updated_at')
            ->select('s.school_id, s.firstname, s.lastname')
            ->select('cs.class_section_name')
            ->join('students s', 's.id = sc.student_id', 'inner')
            ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
            ->where('sc.school_year', $schoolYear)
            ->where('sc.class_section_id IS NOT NULL', null, false);

        if ($hasDob) {
            $builder->select('s.dob');
        }

        if ($hasRegistrationGrade) {
            $builder->select('s.registration_grade');
        }

        if ($hasEventOnly) {
            $builder->groupStart()
                ->where('sc.is_event_only', 0)
                ->orWhere('sc.is_event_only', null)
                ->groupEnd();
        }

        if ($hasActive) {
            $builder->where('s.is_active', 1);
        }

        $assignmentRows = $builder
            ->orderBy('sc.student_id', 'ASC')
            ->orderBy('sc.updated_at', 'DESC')
            ->orderBy('sc.created_at', 'DESC')
            ->get()
            ->getResultArray();

        $students = [];
        foreach ($assignmentRows as $row) {
            $studentId = (int) ($row['student_id'] ?? 0);
            if ($studentId <= 0 || isset($students[$studentId])) {
                continue;
            }

            $students[$studentId] = [
                'student_id' => $studentId,
                'school_id' => (string) ($row['school_id'] ?? ''),
                'student_name' => trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')),
                'class_section_id' => (int) ($row['class_section_id'] ?? 0),
                'class_section_name' => (string) ($row['class_section_name'] ?? ''),
                'dob' => (string) ($row['dob'] ?? ''),
                'registration_grade' => (string) ($row['registration_grade'] ?? ''),
                'auto_kg_pass' => false,
                'year_score' => null,
                'decision' => '',
                'source' => 'missing',
                'notes' => '',
                'status' => 'missing',
                'queue_status' => '',
                'target_class' => '',
                'target_section' => '',
            ];
        }

        if ($students === []) {
            return [
                'summary' => $summary,
                'rows' => [],
            ];
        }

        $decisions = $this->promotionDecisionRows(array_keys($students), $schoolYear);
        $queueRows = $this->promotionQueueRows(array_keys($students), $schoolYear, $targetSchoolYear);
        foreach ($students as $studentId => &$student) {
            $decision = $decisions[$studentId] ?? null;
            if ($decision !== null) {
                $student['year_score'] = $decision['year_score'];
                $student['decision'] = $decision['decision'];
                $student['source'] = $decision['source'];
                $student['notes'] = $decision['notes'];
                $student['status'] = $decision['status'];
                if (($decision['class_section_name'] ?? '') !== '') {
                    $student['class_section_name'] = $decision['class_section_name'];
                }
            }

            if ($decision === null && $this->isKgStudent($student)) {
                $kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear);
                if ($kgAgeStatus === 'pass') {
                    $student['decision'] = 'Pass';
                    $student['source'] = 'automatic_kg_age';
                    $student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.';
                    $student['status'] = 'decided';
                    $student['auto_kg_pass'] = true;
                } elseif ($kgAgeStatus === 'keep_kg') {
                    $student['decision'] = 'Keep KG';
                    $student['source'] = 'automatic_kg_age';
                    $student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.';
                    $student['status'] = 'decided';
                }
            }

            $queue = $queueRows[$studentId] ?? null;
            if ($queue !== null) {
                $student['queue_status'] = $queue['status'];
                $student['target_class'] = $queue['target_class'];
                $student['target_section'] = $queue['target_section'];
            }

            $summary['total_students']++;
            if ($student['status'] === 'missing') {
                $summary['missing_decision']++;
            } elseif ($student['status'] === 'pending') {
                $summary['pending_decision']++;
            } else {
                $summary['with_decision']++;
                if (strcasecmp((string) $student['decision'], 'Pass') === 0) {
                    $summary['pass']++;
                    if ($queue === null && $student['auto_kg_pass'] !== true) {
                        $summary['missing_queue']++;
                    }
                } else {
                    $summary['other_decision']++;
                }
            }

            if ($queue !== null && isset($summary[$queue['status']])) {
                $summary[$queue['status']]++;
            }
        }
        unset($student);

        $rows = array_values($students);
        usort($rows, static function (array $a, array $b): int {
            return [$a['class_section_name'], $a['student_name'], $a['student_id']]
                <=> [$b['class_section_name'], $b['student_name'], $b['student_id']];
        });

        return [
            'summary' => $summary,
            'rows' => $rows,
        ];
    }

    private function isKgStudent(array $student): bool
    {
        foreach (['class_section_name', 'registration_grade'] as $field) {
            $value = strtoupper(trim((string) ($student[$field] ?? '')));
            if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) {
                return true;
            }
        }

        return false;
    }

    private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string
    {
        $dob = trim($dob);
        if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) {
            return '';
        }

        try {
            $birthDate = new \DateTimeImmutable($dob);
            $cutoff = new \DateTimeImmutable($matches[1] . '-12-31');
        } catch (\Throwable) {
            return '';
        }

        if ($birthDate > $cutoff) {
            return '';
        }

        $age = $birthDate->diff($cutoff)->y;
        if ($age >= 6) {
            return 'pass';
        }

        return 'keep_kg';
    }

    private function promotionDecisionRows(array $studentIds, string $schoolYear): array
    {
        if (! $this->db->tableExists('student_decisions')) {
            return [];
        }

        $select = ['student_id', 'class_section_name', 'decision', 'source', 'notes'];
        $scoreField = null;
        if ($this->db->fieldExists('year_score', 'student_decisions')) {
            $scoreField = 'year_score';
        } elseif ($this->db->fieldExists('semester_score', 'student_decisions')) {
            $scoreField = 'semester_score';
        }

        if ($scoreField !== null) {
            $select[] = $scoreField . ' AS year_score';
        }

        $rows = $this->db->table('student_decisions')
            ->select($select)
            ->where('school_year', $schoolYear)
            ->whereIn('student_id', $studentIds)
            ->orderBy('updated_at', 'DESC')
            ->orderBy('id', 'DESC')
            ->get()
            ->getResultArray();

        $decisions = [];
        foreach ($rows as $row) {
            $studentId = (int) ($row['student_id'] ?? 0);
            if ($studentId <= 0 || isset($decisions[$studentId])) {
                continue;
            }

            $decision = trim((string) ($row['decision'] ?? ''));
            $source = trim((string) ($row['source'] ?? ''));
            $status = $decision === '' || $source === 'pending' ? 'pending' : 'decided';

            $decisions[$studentId] = [
                'class_section_name' => (string) ($row['class_section_name'] ?? ''),
                'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null,
                'decision' => $decision,
                'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'),
                'notes' => (string) ($row['notes'] ?? ''),
                'status' => $status,
            ];
        }

        return $decisions;
    }

    private function promotionQueueRows(array $studentIds, string $sourceSchoolYear, ?string $targetSchoolYear): array
    {
        if (
            $targetSchoolYear === null
            || $targetSchoolYear === ''
            || ! $this->db->tableExists('promotion_queue')
        ) {
            return [];
        }

        $builder = $this->db->table('promotion_queue pq')
            ->select('pq.student_id, pq.to_class_id, pq.to_class_section_id, pq.status')
            ->select('c.class_name AS target_class')
            ->select('cs.class_section_name AS target_section')
            ->join('classes c', 'c.id = pq.to_class_id', 'left')
            ->join('classSection cs', 'cs.class_section_id = pq.to_class_section_id', 'left')
            ->where('pq.school_year_from', $sourceSchoolYear)
            ->where('pq.school_year_to', $targetSchoolYear)
            ->whereIn('pq.student_id', $studentIds)
            ->orderBy('pq.updated_at', 'DESC')
            ->orderBy('pq.id', 'DESC');

        $rows = $builder->get()->getResultArray();

        $queueRows = [];
        foreach ($rows as $row) {
            $studentId = (int) ($row['student_id'] ?? 0);
            if ($studentId <= 0 || isset($queueRows[$studentId])) {
                continue;
            }

            $targetClass = trim((string) ($row['target_class'] ?? ''));
            if ($targetClass === '' && (int) ($row['to_class_id'] ?? 0) > 0) {
                $targetClass = 'Class #' . (int) $row['to_class_id'];
            }

            $targetSection = trim((string) ($row['target_section'] ?? ''));
            if ($targetSection === '' && (int) ($row['to_class_section_id'] ?? 0) > 0) {
                $targetSection = 'Section #' . (int) $row['to_class_section_id'];
            }

            $queueRows[$studentId] = [
                'status' => (string) ($row['status'] ?? ''),
                'target_class' => $targetClass,
                'target_section' => $targetSection,
            ];
        }

        return $queueRows;
    }

    private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int
    {
        if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
            return 0;
        }

        $row = $this->db->table($table)
            ->select("COUNT(DISTINCT {$distinctField}) AS total", false)
            ->where('school_year', $schoolYear)
            ->get()
            ->getRowArray();

        return (int) ($row['total'] ?? 0);
    }

    private function countFamilies(string $schoolYear): int
    {
        if ($this->db->tableExists('invoices')) {
            $row = $this->db->table('invoices')
                ->select('COUNT(DISTINCT parent_id) AS total', false)
                ->where('school_year', $schoolYear)
                ->get()
                ->getRowArray();

            return (int) ($row['total'] ?? 0);
        }

        return 0;
    }

    private function countMissingSchoolYearRows(string $table): int
    {
        if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
            return 0;
        }

        return $this->db->table($table)
            ->groupStart()
            ->where('school_year', null)
            ->orWhere('school_year', '')
            ->groupEnd()
            ->countAllResults();
    }

    private function latestOpenBatch(int $sourceYearId): ?array
    {
        return $this->batchModel
            ->where('source_school_year_id', $sourceYearId)
            ->whereIn('status', ['started', 'executed'])
            ->orderBy('id', 'DESC')
            ->first();
    }

    private function assertClosingTablesExist(): void
    {
        foreach (['school_year_closing_batches', 'school_year_closing_items', 'school_year_transition_logs'] as $table) {
            if (! $this->db->tableExists($table)) {
                throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before closing a school year.');
            }
        }
    }

    private function finding(string $severity, string $title, string $detail): array
    {
        return [
            'severity' => $severity,
            'title' => $title,
            'detail' => $detail,
        ];
    }

    private function hashPreview(array $preview): string
    {
        return hash('sha256', json_encode([
            'source_id' => $preview['source']['id'] ?? null,
            'target_id' => $preview['target']['id'] ?? null,
            'finance' => $preview['finance'],
            'promotion' => $preview['promotion'],
            'carry_forward' => $preview['carry_forward'],
            'blockers' => $preview['blockers'],
        ], JSON_UNESCAPED_SLASHES));
    }

    private function syncActiveYearSession(string $schoolYearName): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            return;
        }

        session()->set('school_year', $schoolYearName);
        session()->remove('selected_school_year_id');
        session()->remove('selected_school_year');
    }
}
