<?php

namespace App\Services;

use App\Models\ScheduleEntryModel;
use App\Models\ScheduleLoadModel;
use App\Models\ScheduleTimeslotModel;
use App\Models\ScheduleAvailabilityModel;
use App\Models\ScheduleTeacherSettingModel;

/**
 * ScheduleGeneratorService — ARSITEKTUR BLOCK-CENTRIC
 *
 * Unit optimasi = BLOCK (bukan slot individual).
 * Satu block = satu entitas yang selalu bergerak bersama.
 *
 * Perbaikan arsitektur dari versi sebelumnya:
 * ─────────────────────────────────────────────
 * 1. BLOCK IDENTITY
 *    Setiap block punya block_id unik. Swap dan repair beroperasi
 *    pada block_id, bukan slot individual, sehingga kontiguitas
 *    split_config tidak pernah rusak.
 *
 * 2. AVAILABLE WINDOWS AKURAT
 *    Dihitung sebagai jumlah window KONTIGU ukuran blockSize yang
 *    tersedia, bukan jumlah slot dikurangi blocked.
 *
 * 3. SWAP BERBASIS BLOCK (O(n²) pasangan block, bukan slot)
 *    Dua block ditukar posisi hari+slot_start sekaligus.
 *    Hill climbing: swap hanya dilakukan jika skor membaik.
 *    Skor inkremental — tidak re-scan semua entries.
 *
 * 4. REPAIR TEPAT SASARAN
 *    Hanya menghapus block yang benar-benar gagal ditempatkan
 *    (failedBlocks dari placement phase), bukan seluruh load.
 *
 * 5. BACKTRACKING REALISTIS
 *    Tidak memakai recursive depth-limited yang tidak efektif
 *    untuk 100–300 blok. Diganti dengan:
 *    - Multi-attempt placement dengan shuffle kelompok
 *    - Targeted repair untuk failed blocks
 *    - Block-level swap hill climbing
 *
 * 6. SCORING INKREMENTAL DI SWAP
 *    calculateSwapDelta() menghitung dampak swap tanpa
 *    re-scan seluruh entries — O(blockSize) bukan O(n).
 *
 * Bug fixes (v2):
 * ─────────────────────────────────────────────
 * FIX-1: placementPhase replay kini me-reset SEMUA cache (loadDayMap +
 *    classSubjectDay2) ke baseline retained sebelum replay. Sebelumnya
 *    hanya teacherSlot/classSlot/dayCount yang di-reset, menyebabkan
 *    HC7/HC8 cache kotor dari attempt terakhir dan memblokir penempatan
 *    yang seharusnya valid.
 *
 * FIX-2: isSwapValid HC8 kini memakai guard $dayA !== $dayB sebelum
 *    mengecek loadDayMap. Sebelumnya same-day swap selalu ditolak karena
 *    loadDayMap[$load][$day] selalu true untuk block yang sudah di sana.
 *
 * FIX-3: isSwapValid HC7 kini melakukan scan placedBlocks yang lebih
 *    teliti, mengecualikan block A dan B itu sendiri. Sebelumnya
 *    same-day swap antara block dari kelas berbeda bisa salah ditolak.
 *
 * FIX-4: findBlockById kini O(1) via blockIndex (array[$blockId]).
 *    Sebelumnya O(n) linear scan — lambat untuk 200+ blok dan
 *    dipanggil ratusan kali saat repair+replay.
 *
 * FIX-5: Replay block yang tidak ditemukan di blockIndex kini dicatat
 *    ke log dengan pesan eksplisit, bukan silently dilewati.
 */
class ScheduleGeneratorService
{
    protected ScheduleEntryModel          $entryModel;
    protected ScheduleLoadModel           $loadModel;
    protected ScheduleTimeslotModel       $timeslotModel;
    protected ScheduleAvailabilityModel   $availModel;
    protected ScheduleTeacherSettingModel $settingModel;

    // ── Slot data ────────────────────────────────────────────────────
    protected array $kbmSlots    = [];   // [day][slot_number] => slot row
    protected array $allSlotTypes = [];  // [day][slot_number] => 'kbm'|'istirahat'|...
    protected array $activeDays  = [];
    protected array $blockedMap  = [];   // [teacher_id][day][slot] => true

    // ── Teacher data ─────────────────────────────────────────────────
    protected int   $globalMaxHours        = 6;
    protected array $teacherMaxHoursCache  = [];

    // ── Block registry ───────────────────────────────────────────────
    /** @var array[] list semua block, masing-masing punya block_id unik */
    protected array $allBlocks = [];

    /** @var array index block_id => block untuk O(1) lookup */
    protected array $blockIndex = [];

    /** @var array cache window kontigu KBM valid */
    protected array $validWindowsCache = [];

    // ── Runtime state ────────────────────────────────────────────────
    /**
     * placedBlocks[block_id] = [
     *   'block_id'    => int,
     *   'load_id'     => int,
     *   'teacher_id'  => int,
     *   'class_id'    => int,
     *   'subject_id'  => int,
     *   'day'         => int,
     *   'slots'       => int[],   // slot numbers, selalu kontigu
     *   'is_retained' => bool,
     * ]
     */
    protected array $placedBlocks = [];
    protected array $placedBlocksByClass = [];
    protected array $placedBlocksBySize = [];

    /**
     * State array untuk constraint check (diupdate setiap place/remove/swap).
     * teacherSlot[teacher_id][day][slot] => block_id
     * classSlot[class_id][day][slot]     => block_id
     * dayCount[teacher_id][day]          => int (jumlah slot)
     * loadDayMap[load_id][day]           => true  ← HC8 cache O(1)
     * classSubjectDay[class_id][day][subject_id] => true ← HC7 cache O(1)
     */
    protected array $teacherSlot      = [];
    protected array $classSlot        = [];
    protected array $dayCount         = [];
    protected array $loadDayMap       = [];   // HC8: load sudah di hari mana
    protected array $classSubjectDay2 = [];   // HC7: mapel sudah di hari mana per kelas
    protected int   $availabilityConflictsCount = 0;
    protected int   $teacherClashesCount = 0;
    protected int   $classClashesCount = 0;
    protected int   $hc6OverHours = 0;
    protected int   $hc7ConflictsCount = 0;
    protected int   $hc8ConflictsCount = 0;
    protected int   $lessonDistributionPenalty = 0;
    protected array $classSubjectLoadCount = [];
    protected array $loadLessonDistributionPenalty = [];
    protected int   $backtrackStates = 0;

    // ── Konfigurasi soft constraint ──────────────────────────────────
    // HC6 adalah soft constraint: generator berusaha menghormatinya,
    // tapi boleh dilanggar di repair/last-resort phase dengan penalty
    protected int $hc6SoftPenalty    = 20;   // penalty skor untuk pelanggaran HC6
    protected int $swapIterations    = 10000;
    protected int $repairRounds      = 100;
    protected int $placementAttempts = 100;

    protected array $logs = [];

    // Profiling
    protected float $timePlacement = 0;
    protected float $timeRepair    = 0;
    protected float $timeSwap      = 0;
    protected float $timeTotal     = 0;

    public function __construct()
    {
        $this->entryModel    = new ScheduleEntryModel();
        $this->loadModel     = new ScheduleLoadModel();
        $this->timeslotModel = new ScheduleTimeslotModel();
        $this->availModel    = new ScheduleAvailabilityModel();
        $this->settingModel  = new ScheduleTeacherSettingModel();
    }

    // ══════════════════════════════════════════════════════════════════
    // ENTRY POINT
    // ══════════════════════════════════════════════════════════════════

    public function generate(
        int    $scheduleId,
        int    $yearId,
        int    $periodId,
        string $activeDays = '1,2,3,4,5',
        bool   $keepManual = false
    ): array {
        $this->reset();
        $this->timeTotal      = microtime(true);
        $this->globalMaxHours = $this->settingModel->getGlobalMaxHours();

        $this->deleteNonLockedGenerated($scheduleId, $keepManual);

        $this->activeDays = array_map('intval', explode(',', $activeDays));
        $this->loadKbmSlots();
        $this->loadAllSlotTypes();
        $this->loadBlockedMap();
        $this->precalculateValidWindows();
        $this->preloadTeacherData();

        // Muat entri retained (locked/manual) ke state
        $this->loadRetainedEntries($scheduleId, $keepManual);

        $loads = $this->loadModel->getForGenerator($yearId, $periodId);
        if (empty($loads)) {
            return $this->buildResponse(0, 0, [], ['Tidak ada beban mengajar']);
        }

        $totalJam = array_sum(array_column($loads, 'hours_per_week'));

        // ── TAHAP 1: Bangun block registry ───────────────────────────
        $this->buildBlocks($loads);

        // ── PERBAIKAN 1: Sort difficulty ──────────────────────────────
        // Guru dengan banyak kelas/load dikerjakan DULUAN
        // agar hari-hari terbaik tidak habis diambil blok mudah

        // Hitung jumlah total blok per guru
        $blockCountPerTeacher = [];
        foreach ($this->allBlocks as $b) {
            $tid = $b['teacher_id'];
            $blockCountPerTeacher[$tid] = ($blockCountPerTeacher[$tid] ?? 0) + 1;
        }

        usort($this->allBlocks, function($a, $b) use ($blockCountPerTeacher) {
            // Utamakan: difficulty tinggi
            if ($b['difficulty'] !== $a['difficulty'])
                return $b['difficulty'] <=> $a['difficulty'];
            // Tiebreaker: guru dengan lebih banyak blok duluan
            $nA = $blockCountPerTeacher[$a['teacher_id']] ?? 0;
            $nB = $blockCountPerTeacher[$b['teacher_id']] ?? 0;
            if ($nA !== $nB) return $nB <=> $nA;
            // Tiebreaker: ukuran blok lebih besar duluan
            return $b['size'] <=> $a['size'];
        });

        $this->logs[] = "Total blok   : " . count($this->allBlocks);
        $this->logs[] = "Total jam    : {$totalJam}";
        $this->logs[] = "Attempts     : {$this->placementAttempts}";

        // ── TAHAP 2: Classroom Packing Phase ──────────────────────────
        $this->logs[] = '';
        $this->logs[] = '=== CLASSROOM PACKING PHASE ===';
        $t = microtime(true);
        $failedPackBlocks = [];
        $packSuccess = $this->packClassrooms($failedPackBlocks);
        if (!$packSuccess) {
            $this->logs[] = "  ⚠️ Gagal mengemas " . count($failedPackBlocks) . " blok secara bersih. Menggunakan fallback greedy placement untuk blok tersebut...";
            $failedGreedy = $this->placementPhase($failedPackBlocks);
            if (!empty($failedGreedy)) {
                $this->logs[] = "  Menempatkan " . count($failedGreedy) . " blok yang gagal...";
                foreach ($failedGreedy as $fb) {
                    $window = $this->findMinPenaltyWindow($fb);
                    if ($window !== null) {
                        $this->placeBlock($fb, $window['day'], $window['slots']);
                    } else {
                        // Fallback absolute
                        $size = $fb['size'];
                        $placedFallback = false;
                        foreach ($this->activeDays as $day) {
                            $kbmNums = array_keys($this->kbmSlots[$day] ?? []);
                            sort($kbmNums);
                            if (count($kbmNums) >= $size) {
                                $window = array_slice($kbmNums, 0, $size);
                                $this->placeBlock($fb, $day, $window);
                                $placedFallback = true;
                                break;
                            }
                        }
                    }
                }
            }
        }
        $this->timePlacement = microtime(true) - $t;
        $this->timeRepair = 0;

        // ── TAHAP 3: Optimization Phase (Simulated Annealing) ─────────
        $this->logs[] = '';
        $this->logs[] = '=== OPTIMIZATION PHASE (SIMULATED ANNEALING) ===';
        $t = microtime(true);

        $this->localSearchOptimization(300000); // Run Simulated Annealing with 300k iterations and time guard

        $this->timeSwap = microtime(true) - $t;

        // ── Simpan ke database ────────────────────────────────────────
        $batchInsert = [];
        $totalPlaced = 0;

        foreach ($this->placedBlocks as $pb) {
            if (!empty($pb['is_retained'])) {
                $totalPlaced += count($pb['slots']);
                continue;
            }
            foreach ($pb['slots'] as $slot) {
                $batchInsert[] = [
                    'schedule_id'       => $scheduleId,
                    'schedule_load_id'  => $pb['load_id'],
                    'teacher_id'        => $pb['teacher_id'],
                    'classroom_id'      => $pb['class_id'],
                    'school_subject_id' => $pb['subject_id'],
                    'day'               => $pb['day'],
                    'slot_number'       => $slot,
                    'timeslot_id'       => $this->kbmSlots[$pb['day']][$slot]['id'],
                    'is_manual'         => 0,
                    'is_locked'         => 0,
                    'created_at'        => date('Y-m-d H:i:s'),
                    'updated_at'        => date('Y-m-d H:i:s'),
                ];
                $totalPlaced++;
            }
        }

        if (!empty($batchInsert)) {
            $this->entryModel->db->reconnect(); // Reconnect db to prevent "MySQL server has gone away" after long CPU loops
            $this->entryModel->db->table('schedule_entries')->insertBatch($batchInsert);
        }

        $conflicts  = $this->calculateConflicts($loads);
        $timeTaken  = round(microtime(true) - $this->timeTotal, 2);
        $percentage = $totalJam > 0 ? round($totalPlaced / $totalJam * 100) : 0;

        $this->logs[] = '';
        $this->logs[] = '=== PROFILING ===';
        $this->logs[] = "Placement : " . round($this->timePlacement, 2) . " detik";
        $this->logs[] = "Repair    : " . round($this->timeRepair, 2) . " detik";
        $this->logs[] = "Swap/Opt  : " . round($this->timeSwap, 2) . " detik";
        $this->logs[] = "Total     : {$timeTaken} detik";
        $this->logs[] = "Hasil     : {$totalPlaced} / {$totalJam} ({$percentage}%)";

        return $this->buildResponse($totalPlaced, $totalJam, $conflicts, $this->logs);
    }

    // ══════════════════════════════════════════════════════════════════
    // TAHAP 1 — BANGUN BLOCK REGISTRY
    // ══════════════════════════════════════════════════════════════════

    /**
     * Setiap block mendapat block_id unik.
     *
     * Difficulty = (blockSize / contigousWindowsForSize) * 100
     * → semakin sedikit window kontigu untuk ukuran block ini,
     *   semakin tinggi difficulty, dipasang lebih dulu.
     */
    protected function buildBlocks(array $loads): void
    {
        $blockId = 0;

        foreach ($loads as $load) {
            $teacherId  = (int)$load['teacher_id'];
            $totalHours = (int)$load['hours_per_week'];
            $sizes      = $this->parseSplitConfig($totalHours, $load['split_config'] ?? '');
            rsort($sizes);

            foreach ($sizes as $size) {
                $windows    = $this->countContigousWindows($teacherId, $size);
                $difficulty = $size / max(1, $windows) * 100;

                $block = [
                    'block_id'   => $blockId,
                    'load_id'    => $load['id'],
                    'teacher_id' => $teacherId,
                    'class_id'   => (int)$load['classroom_id'],
                    'subject_id' => (int)$load['school_subject_id'],
                    'size'       => $size,
                    'difficulty' => $difficulty,
                ];

                $this->allBlocks[]         = $block;
                $this->blockIndex[$blockId] = $block;
                $blockId++;
            }
        }
    }

    /**
     * Hitung jumlah window kontigu ukuran $size yang tersedia untuk guru $teacherId.
     * Ini adalah difficulty metric yang akurat.
     */
    protected function countContigousWindows(int $teacherId, int $size): int
    {
        $count = 0;
        foreach ($this->activeDays as $day) {
            $slots = array_keys($this->kbmSlots[$day] ?? []);
            $n     = count($slots);
            for ($i = 0; $i <= $n - $size; $i++) {
                $window = array_slice($slots, $i, $size);
                $ok = true;
                foreach ($window as $slot) {
                    if ($this->isBlocked($teacherId, $day, $slot)) {
                        $ok = false;
                        break;
                    }
                }
                if ($ok) $count++;
            }
        }
        return $count;
    }

    // ══════════════════════════════════════════════════════════════════
    // TAHAP 2 — PLACEMENT PHASE
    // ══════════════════════════════════════════════════════════════════

    /**
     * Multi-attempt placement dengan shuffle kelompok (bukan shuffle total).
     * Tiap attempt dimulai dari state kosong (retained tetap ada).
     * Kembalikan daftar block yang gagal ditempatkan pada attempt terbaik.
     */
    protected function placementPhase(array $blocks): array
    {
        // Simpan state retained untuk reset antar attempt
        $retainedTeacherSlot    = $this->teacherSlot;
        $retainedClassSlot      = $this->classSlot;
        $retainedDayCount       = $this->dayCount;
        $retainedPlaced         = $this->placedBlocks;
        $retainedPlacedBlocksByClass = $this->placedBlocksByClass;
        $retainedPlacedBlocksBySize = $this->placedBlocksBySize;
        $retainedLoadDayMap     = $this->loadDayMap;
        $retainedClassSubjDay   = $this->classSubjectDay2;
        $retainedAvailabilityConflictsCount = $this->availabilityConflictsCount;
        $retainedTeacherClashesCount = $this->teacherClashesCount;
        $retainedClassClashesCount = $this->classClashesCount;
        $retainedHc6OverHours = $this->hc6OverHours;
        $retainedHc7ConflictsCount = $this->hc7ConflictsCount;
        $retainedHc8ConflictsCount = $this->hc8ConflictsCount;
        $retainedLessonDistributionPenalty = $this->lessonDistributionPenalty;
        $retainedClassSubjectLoadCount = $this->classSubjectLoadCount;
        $retainedLoadLessonDistributionPenalty = $this->loadLessonDistributionPenalty;

        $bestFailed  = $blocks; // worst case: semua gagal
        $bestPlaced  = [];

        for ($attempt = 0; $attempt < $this->placementAttempts; $attempt++) {
            // Reset ke state retained
            $this->teacherSlot      = $retainedTeacherSlot;
            $this->classSlot        = $retainedClassSlot;
            $this->dayCount         = $retainedDayCount;
            $this->placedBlocks     = $retainedPlaced;
            $this->placedBlocksByClass = $retainedPlacedBlocksByClass;
            $this->placedBlocksBySize = $retainedPlacedBlocksBySize;
            $this->loadDayMap       = $retainedLoadDayMap;
            $this->classSubjectDay2 = $retainedClassSubjDay;
            $this->availabilityConflictsCount = $retainedAvailabilityConflictsCount;
            $this->teacherClashesCount = $retainedTeacherClashesCount;
            $this->classClashesCount = $retainedClassClashesCount;
            $this->hc6OverHours = $retainedHc6OverHours;
            $this->hc7ConflictsCount = $retainedHc7ConflictsCount;
            $this->hc8ConflictsCount = $retainedHc8ConflictsCount;
            $this->lessonDistributionPenalty = $retainedLessonDistributionPenalty;
            $this->classSubjectLoadCount = $retainedClassSubjectLoadCount;
            $this->loadLessonDistributionPenalty = $retainedLoadLessonDistributionPenalty;

            $attemptBlocks = $blocks;

            // Shuffle kelompok (pertahankan urutan difficulty global) atau full shuffle
            if ($attempt > 0) {
                if ($attempt % 3 === 0) {
                    // Complete random shuffle to escape systematic greedy bottlenecks
                    shuffle($attemptBlocks);
                } else {
                    // Group shuffle to preserve difficulty ranking locally
                    $groupSize = 12;
                    for ($i = 0; $i < count($attemptBlocks); $i += $groupSize) {
                        $slice = array_slice($attemptBlocks, $i, $groupSize);
                        shuffle($slice);
                        array_splice($attemptBlocks, $i, $groupSize, $slice);
                    }
                }
            }

            $failed = [];

            foreach ($attemptBlocks as $block) {
                // Stage 1: strict placement
                $window = $this->findBestWindow($block, false, false, false);
                if ($window === null) {
                    // Stage 2: relax HC6 (teacher max hours per day)
                    $window = $this->findBestWindow($block, true, false, false);
                }
                if ($window === null) {
                    // Stage 3: relax HC6 + HC8 (allow same load in same day)
                    $window = $this->findBestWindow($block, true, false, true);
                }
                if ($window === null) {
                    // Stage 4: relax HC6 + HC7 + HC8 (allow same subject in same day)
                    $window = $this->findBestWindow($block, true, true, true);
                }

                if ($window !== null) {
                    $this->placeBlock($block, $window['day'], $window['slots']);
                } else {
                    $failed[] = $block;
                }
            }

            if (count($failed) < count($bestFailed)) {
                $bestFailed = $failed;
                $bestPlaced = $this->placedBlocks;

                if (empty($bestFailed)) {
                    $this->logs[] = "  ✅ Solusi sempurna pada attempt " . ($attempt + 1);
                    break;
                }
            }
        }

        // Terapkan state terbaik — reset SEMUA cache ke baseline retained
        $this->teacherSlot      = $retainedTeacherSlot;
        $this->classSlot        = $retainedClassSlot;
        $this->dayCount         = $retainedDayCount;
        $this->placedBlocks     = $retainedPlaced;
        $this->placedBlocksByClass = $retainedPlacedBlocksByClass;
        $this->placedBlocksBySize = $retainedPlacedBlocksBySize;
        $this->loadDayMap       = $retainedLoadDayMap;
        $this->classSubjectDay2 = $retainedClassSubjDay;
        $this->availabilityConflictsCount = $retainedAvailabilityConflictsCount;
        $this->teacherClashesCount = $retainedTeacherClashesCount;
        $this->classClashesCount = $retainedClassClashesCount;
        $this->hc6OverHours = $retainedHc6OverHours;
        $this->hc7ConflictsCount = $retainedHc7ConflictsCount;
        $this->hc8ConflictsCount = $retainedHc8ConflictsCount;
        $this->lessonDistributionPenalty = $retainedLessonDistributionPenalty;
        $this->classSubjectLoadCount = $retainedClassSubjectLoadCount;
        $this->loadLessonDistributionPenalty = $retainedLoadLessonDistributionPenalty;

        // Replay best placement ke state
        foreach ($bestPlaced as $pb) {
            if (!empty($pb['is_retained'])) continue;
            $block = $this->findBlockById($pb['block_id']);
            if ($block === null) {
                $this->logs[] = "  ⚠️ Replay: block_id {$pb['block_id']} tidak ditemukan di allBlocks";
                continue;
            }
            $this->placeBlock($block, $pb['day'], $pb['slots']);
        }

        return $bestFailed;
    }

        /**
     * Cari window terbaik untuk sebuah block.
     * Mengembalikan ['day' => int, 'slots' => int[]] atau null.
     *
     * HC5: kontigu — boleh melompati non-KBM, tidak boleh melompati KBM lain
     * HC6: max_hours_per_day
     * HC7: mapel tidak 2x sehari per kelas
     * HC8: load yang sama tidak boleh di hari yang sama
     */
    protected function findBestWindow(array $block, bool $relaxHC6 = false, bool $relaxHC7 = false, bool $relaxHC8 = false): ?array
    {
        $teacherId = $block['teacher_id'];
        $classId   = $block['class_id'];
        $subjectId = $block['subject_id'];
        $loadId    = $block['load_id'];
        $size      = $block['size'];

        $candidates = [];

        // Shuffle hari untuk distribusi merata
        $days = $this->activeDays;
        shuffle($days);

        foreach ($days as $day) {
            // HC8: load ini sudah ada di hari ini?
            if (!$relaxHC8 && !$this->checkHC8($loadId, $day)) continue;

            // HC7: mapel sudah ada di hari ini untuk kelas ini dari load berbeda?
            if (!$relaxHC7 && !$this->checkHC7($classId, $subjectId, $day, $loadId)) continue;

            // HC6: strict di placement normal, soft (skip) di repair last-resort
            if (!$relaxHC6 && !$this->checkHC6($teacherId, $day, $size, true)) continue;

            // Ambil semua slot KBM di hari ini (urut)
            $kbmNums = array_keys($this->kbmSlots[$day] ?? []);
            sort($kbmNums);
            $nKbm = count($kbmNums);
            if ($nKbm < $size) continue;

            // Spread bonus: hindari guru numpuk di satu hari
            $currentHours = $this->dayCount[$teacherId][$day] ?? 0;
            $spreadBonus  = $currentHours === 0 ? 30 : ($currentHours <= $size ? 10 : 0);

            // Penalty kalau HC6 dilanggar (soft mode)
            $hc6pen = $relaxHC6 ? $this->hc6Penalty($teacherId, $day, $size) : 0;

            // Cari semua window ukuran $size dari daftar KBM
            for ($i = 0; $i <= $nKbm - $size; $i++) {
                $window = array_slice($kbmNums, $i, $size);

                // HC5: cek kontigu (tidak boleh ada KBM lain di antara window)
                if (!$this->checkHC5Contiguous($day, $window)) continue;

                // HC1, HC2, HC3, HC4
                if (!$this->isWindowFree($teacherId, $classId, $day, $window)) continue;

                // Skor: lebih suka slot awal, spread bonus, kurangi penalty HC6, sedikit random
                $score = (50 - $window[0]) + $spreadBonus - $hc6pen + mt_rand(1, 5);

                $candidates[] = [
                    'day'      => $day,
                    'slots'    => $window,
                    'score'    => $score,
                    'hc6_over' => $hc6pen > 0, // tandai jika melanggar HC6
                ];
            }
        }

        if (empty($candidates)) return null;

        usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']);
        $top = array_slice($candidates, 0, min(5, count($candidates)));
        return $top[array_rand($top)];
    }

    // ══════════════════════════════════════════════════════════════════
    // TAHAP 3 — REPAIR PHASE
    // ══════════════════════════════════════════════════════════════════

    /**
     * Hanya menargetkan block yang benar-benar gagal (failedBlocks).
     *
     * Strategi berlapis:
     * 1. Round normal: tryRepairBlock (geser 1 atau multi obstruktor)
     * 2. Deep repair: tryDeepRepair — coba geser ≥2 obstruktor + buka
     *    ruang guru. Menangani kasus blok 2 jam di mana setiap window
     *    terisi oleh 2 blok berbeda.
     * 3. Last resort: relax HC7 jika masih ada yang gagal.
     */
    /**
     * Mencari window pelajaran berukuran $size yang menghasilkan penalti total terkecil.
     * Digunakan untuk menempatkan blok gagal secara paksa sebelum optimasi dimulai.
     */
    protected function findMinPenaltyWindow(array $block): ?array
    {
        $bestWindow = null;
        $minPenalty = INF;
        $size = $block['size'];

        // Coba semua hari aktif
        foreach ($this->activeDays as $day) {
            $kbmNums = array_keys($this->kbmSlots[$day] ?? []);
            sort($kbmNums);
            $nKbm = count($kbmNums);
            if ($nKbm < $size) continue;

            for ($i = 0; $i <= $nKbm - $size; $i++) {
                $window = array_slice($kbmNums, $i, $size);

                // HC5: harus kontigu
                if (!$this->checkHC5Contiguous($day, $window)) continue;

                // Tempatkan sementara
                $this->placeBlock($block, $day, $window);

                // Hitung penalti total
                $penalty = $this->calculateTotalPenalty();

                // Lepas kembali
                $this->removeBlock($block['block_id']);

                if ($penalty < $minPenalty) {
                    $minPenalty = $penalty;
                    $bestWindow = ['day' => $day, 'slots' => $window];
                }
            }
        }

        return $bestWindow;
    }

    /**
     * Hitung total skor penalti untuk seluruh jadwal saat ini.
     * Mengisi array $conflictedBlocks dengan ID blok yang terlibat dalam pelanggaran aturan (opsional).
     */
    public function calculateTotalPenalty(array &$conflictedBlocks = null): int
    {
        if ($conflictedBlocks === null) {
            // O(1) Fast path using dynamic counter cache properties!
            return $this->teacherClashesCount * 10000
                 + $this->classClashesCount * 100000
                 + $this->availabilityConflictsCount * 10000
                 + $this->hc6OverHours * 50
                 + $this->hc7ConflictsCount * 100
                 + $this->hc8ConflictsCount * 100
                 + $this->lessonDistributionPenalty;
        }

        $penalty = 0;
        $rawConflicts = [];

        // 1. Bentrok Guru (Guru mengajar > 1 kelas di jam yang sama) - HARD
        foreach ($this->teacherSlot as $tId => $days) {
            foreach ($days as $day => $slots) {
                foreach ($slots as $slot => $bids) {
                    $cnt = count($bids);
                    if ($cnt > 1) {
                        $penalty += ($cnt - 1) * 10000;
                        foreach (array_keys($bids) as $bid) {
                            $rawConflicts[$bid] = true;
                        }
                    }
                }
            }
        }

        // 2. Bentrok Kelas (Kelas diisi > 1 guru/blok di jam yang sama) - HARD
        foreach ($this->classSlot as $cId => $days) {
            foreach ($days as $day => $slots) {
                foreach ($slots as $slot => $bids) {
                    $cnt = count($bids);
                    if ($cnt > 1) {
                        $penalty += ($cnt - 1) * 100000;
                        foreach (array_keys($bids) as $bid) {
                            $rawConflicts[$bid] = true;
                        }
                    }
                }
            }
        }

        // 3. Guru Mengajar di Slot Availability yang Diblokir (Availability Conflict) - HARD
        $penalty += $this->availabilityConflictsCount * 10000;
        if ($this->availabilityConflictsCount > 0) {
            foreach ($this->placedBlocks as $bid => $pb) {
                $tId = $pb['teacher_id'];
                $day = $pb['day'];
                foreach ($pb['slots'] as $slot) {
                    if ($this->isBlocked($tId, $day, $slot)) {
                        $rawConflicts[$bid] = true;
                    }
                }
            }
        }

        // 4. Guru Melebihi Jam Maksimal per Hari (HC6 - Soft Constraint)
        foreach ($this->dayCount as $tId => $days) {
            $maxHours = $this->teacherMaxHoursCache[$tId] ?? $this->globalMaxHours;
            foreach ($days as $day => $hours) {
                if ($hours > $maxHours) {
                    $penalty += ($hours - $maxHours) * 50;
                }
            }
        }

        // 5. Mapel yang sama diajarkan > 1x sehari di kelas yang sama dari load berbeda (HC7 - Soft Constraint)
        foreach ($this->classSubjectDay2 as $cid => $days) {
            foreach ($days as $day => $subjs) {
                foreach ($subjs as $sid => $bids) {
                    $loads = [];
                    foreach (array_keys($bids) as $bid) {
                        $pb = $this->placedBlocks[$bid] ?? null;
                        if ($pb) {
                            $loads[$pb['load_id']] = true;
                        }
                    }
                    $cnt = count($loads);
                    if ($cnt > 1) {
                        $penalty += ($cnt - 1) * 100;
                    }
                }
            }
        }

        // 6. Blok dari load yang sama berada di hari yang sama (HC8 - Soft Constraint) & Lesson Distribution (consecutive days)
        foreach ($this->loadDayMap as $lid => $days) {
            $uniqueDays = [];
            foreach ($days as $day => $bids) {
                $cnt = count($bids);
                if ($cnt > 0) {
                    $uniqueDays[] = $day;
                }
                if ($cnt > 1) {
                    $penalty += ($cnt - 1) * 100;
                }
            }

            // Lesson Distribution
            if (count($uniqueDays) > 1) {
                sort($uniqueDays);
                for ($idx = 0; $idx < count($uniqueDays) - 1; $idx++) {
                    if ($uniqueDays[$idx + 1] - $uniqueDays[$idx] === 1) {
                        $penalty += 20;
                    }
                }
            }
        }

        $conflictedBlocks = array_keys($rawConflicts);
        return $penalty;
    }

    /**
     * Hitung penalti lesson distribution untuk load tertentu
     */
    protected function calculateLoadLessonDistributionPenalty(int $lid): int
    {
        $daysMap = $this->loadDayMap[$lid] ?? [];
        $uniqueDays = [];
        foreach ($daysMap as $day => $bids) {
            if (!empty($bids)) {
                $uniqueDays[] = $day;
            }
        }
        if (count($uniqueDays) <= 1) {
            return 0;
        }
        sort($uniqueDays);
        $penalty = 0;
        for ($idx = 0; $idx < count($uniqueDays) - 1; $idx++) {
            if ($uniqueDays[$idx + 1] - $uniqueDays[$idx] === 1) {
                $penalty += 20;
            }
        }
        return $penalty;
    }

    /**
     * Fase optimasi lokal menggunakan Simulated Annealing.
     * Mengubah posisi blok secara iteratif untuk menurunkan total penalti menjadi 0.
     */
    protected function localSearchOptimization(int $maxIterations = 150000): void
    {
        $bestPlaced = $this->placedBlocks;
        $bestPlacedBlocksByClass = $this->placedBlocksByClass;
        $bestPlacedBlocksBySize = $this->placedBlocksBySize;
        $bestTeacherSlot = $this->teacherSlot;
        $bestClassSlot = $this->classSlot;
        $bestDayCount = $this->dayCount;
        $bestLoadDayMap = $this->loadDayMap;
        $bestClassSubjectDay2 = $this->classSubjectDay2;
        $bestAvailabilityConflictsCount = $this->availabilityConflictsCount;
        $bestTeacherClashesCount = $this->teacherClashesCount;
        $bestClassClashesCount = $this->classClashesCount;
        $bestHc6OverHours = $this->hc6OverHours;
        $bestHc7ConflictsCount = $this->hc7ConflictsCount;
        $bestHc8ConflictsCount = $this->hc8ConflictsCount;
        $bestLessonDistributionPenalty = $this->lessonDistributionPenalty;
        $bestClassSubjectLoadCount = $this->classSubjectLoadCount;
        $bestLoadLessonDistributionPenalty = $this->loadLessonDistributionPenalty;

        $conflicted = [];
        $currentPenalty = $this->calculateTotalPenalty($conflicted);
        $bestPenalty = $currentPenalty;

        $fastPenalty = $this->calculateTotalPenalty();
        $this->logs[] = "  [DEBUG] Initial Slow Penalty: {$currentPenalty} | Fast Penalty: {$fastPenalty}";
        if ($currentPenalty !== $fastPenalty) {
            $slowTeacherClashes = 0;
            foreach ($this->teacherSlot as $tId => $days) {
                foreach ($days as $day => $slots) {
                    foreach ($slots as $slot => $bids) {
                        $cnt = count($bids);
                        if ($cnt > 1) $slowTeacherClashes += ($cnt - 1);
                    }
                }
            }
            $slowClassClashes = 0;
            foreach ($this->classSlot as $cId => $days) {
                foreach ($days as $day => $slots) {
                    foreach ($slots as $slot => $bids) {
                        $cnt = count($bids);
                        if ($cnt > 1) $slowClassClashes += ($cnt - 1);
                    }
                }
            }
            $slowHC6 = 0;
            foreach ($this->dayCount as $tId => $days) {
                $maxHours = $this->teacherMaxHoursCache[$tId] ?? $this->globalMaxHours;
                foreach ($days as $day => $hours) {
                    if ($hours > $maxHours) {
                        $slowHC6 += ($hours - $maxHours);
                    }
                }
            }
            $slowHC7 = 0;
            foreach ($this->classSubjectDay2 as $cid => $days) {
                foreach ($days as $day => $subjs) {
                    foreach ($subjs as $sid => $bids) {
                        $loads = [];
                        foreach (array_keys($bids) as $bid) {
                            $pb = $this->placedBlocks[$bid] ?? null;
                            if ($pb) {
                                $loads[$pb['load_id']] = true;
                            }
                        }
                        $cnt = count($loads);
                        if ($cnt > 1) $slowHC7 += ($cnt - 1);
                    }
                }
            }
            $slowHC8 = 0;
            $slowLD = 0;
            foreach ($this->loadDayMap as $lid => $days) {
                $uniqueDays = [];
                foreach ($days as $day => $bids) {
                    $cnt = count($bids);
                    if ($cnt > 0) {
                        $uniqueDays[] = $day;
                    }
                    if ($cnt > 1) {
                        $slowHC8 += ($cnt - 1);
                    }
                }
                if (count($uniqueDays) > 1) {
                    sort($uniqueDays);
                    for ($idx = 0; $idx < count($uniqueDays) - 1; $idx++) {
                        if ($uniqueDays[$idx + 1] - $uniqueDays[$idx] === 1) {
                            $slowLD += 20;
                        }
                    }
                }
            }

            $this->logs[] = "  [DEBUG] MISMATCH DETAIL:";
            $this->logs[] = "    Teacher Clashes: {$slowTeacherClashes} vs {$this->teacherClashesCount}";
            $this->logs[] = "    Class Clashes: {$slowClassClashes} vs {$this->classClashesCount}";
            $this->logs[] = "    HC6 Over Hours: {$slowHC6} vs {$this->hc6OverHours}";
            $this->logs[] = "    HC7 Conflicts: {$slowHC7} vs {$this->hc7ConflictsCount}";
            $this->logs[] = "    HC8 Conflicts: {$slowHC8} vs {$this->hc8ConflictsCount}";
            $this->logs[] = "    Lesson Distribution: {$slowLD} vs {$this->lessonDistributionPenalty}";
        }

        if ($currentPenalty === 0) {
            $this->logs[] = "  🎉 Jadwal sudah sempurna pada penempatan awal!";
            return;
        }

        $this->logs[] = "  Awal Optimasi - Penalti: {$currentPenalty}";

        // Parameter Simulated Annealing (Tinggi awal agar bisa melakukan chain-swaps temporer)
        $temp = 1000.0;
        $coolingRate = 0.99998; // Sangat lambat agar tidak buru-buru membeku

        $successfulMoves = 0;
        $successfulSwaps = 0;
        $iterationsSinceImprovement = 0;

        $startTime = microtime(true);
        $timeLimit = 15.0; // 15 seconds maximum time for optimization phase to prevent PHP script timeouts

        for ($iter = 0; $iter < $maxIterations; $iter++) {
            if ($currentPenalty === 0) {
                break;
            }

            // Check time elapsed every 1000 iterations to avoid microtime() overhead
            if ($iter % 1000 === 0) {
                if (microtime(true) - $startTime > $timeLimit) {
                    $this->logs[] = "  ⚠️ Optimasi dihentikan lebih awal karena batas waktu ({$timeLimit}s) tercapai pada iterasi {$iter}";
                    break;
                }
            }

            // Reheating / Reset ke best state jika stuck
            if ($iterationsSinceImprovement >= 30000) {
                // Hard restart: reset ke best state yang pernah ditemukan
                $this->placedBlocks = $bestPlaced;
                $this->placedBlocksByClass = $bestPlacedBlocksByClass;
                $this->placedBlocksBySize = $bestPlacedBlocksBySize;
                $this->teacherSlot = $bestTeacherSlot;
                $this->classSlot = $bestClassSlot;
                $this->dayCount = $bestDayCount;
                $this->loadDayMap = $bestLoadDayMap;
                $this->classSubjectDay2 = $bestClassSubjectDay2;
                $this->availabilityConflictsCount = $bestAvailabilityConflictsCount;
                $this->teacherClashesCount = $bestTeacherClashesCount;
                $this->classClashesCount = $bestClassClashesCount;
                $this->hc6OverHours = $bestHc6OverHours;
                $this->hc7ConflictsCount = $bestHc7ConflictsCount;
                $this->hc8ConflictsCount = $bestHc8ConflictsCount;
                $this->lessonDistributionPenalty = $bestLessonDistributionPenalty;
                $this->classSubjectLoadCount = $bestClassSubjectLoadCount;
                $this->loadLessonDistributionPenalty = $bestLoadLessonDistributionPenalty;
                $currentPenalty = $bestPenalty;
                $temp = 1000.0; // Reheat tinggi
                $iterationsSinceImprovement = 0;
                $conflicted = [];
            } elseif ($iterationsSinceImprovement > 0 && $iterationsSinceImprovement % 5000 === 0) {
                // Soft reheat: naikkan suhu saja untuk biarkan berjalan keluar optimal lokal
                $temp = 1000.0;
            }

            $iterationsSinceImprovement++;

            // Refresh daftar konflik setiap 500 iterasi agar heuristic tetap akurat (optimized from 100 to reduce CPU overhead)
            if ($iter % 500 === 0 || empty($conflicted)) {
                $this->calculateTotalPenalty($conflicted);
            }

            // Turunkan suhu
            $temp *= $coolingRate;

            // 1. Pilih blok target (Conflict-directed Heuristic)
            $targetBid = null;
            if (!empty($conflicted) && mt_rand(1, 100) <= 90) {
                // Hanya pilih blok yang bukan retained/locked
                $validConflicted = [];
                foreach ($conflicted as $bid) {
                    $pb = $this->placedBlocks[$bid] ?? null;
                    if ($pb && empty($pb['is_retained'])) {
                        $validConflicted[] = $bid;
                    }
                }
                if (!empty($validConflicted)) {
                    $targetBid = $validConflicted[array_rand($validConflicted)];
                }
            }

            if ($targetBid === null) {
                $nonRetainedBids = [];
                foreach ($this->placedBlocks as $bid => $pb) {
                    if (empty($pb['is_retained'])) {
                        $nonRetainedBids[] = $bid;
                    }
                }
                if (!empty($nonRetainedBids)) {
                    $targetBid = $nonRetainedBids[array_rand($nonRetainedBids)];
                }
            }

            if ($targetBid === null) continue;

            $pbTarget = $this->placedBlocks[$targetBid];
            $blockTarget = $this->findBlockById($targetBid);
            if (!$blockTarget) continue;

            // 50% swap, 50% move
            $doSwap = mt_rand(0, 1) === 1;

            if ($doSwap) {
                $candidates = [];
                $cid = $pbTarget['class_id'];
                $size = count($pbTarget['slots']);
                if (isset($this->placedBlocksByClass[$cid])) {
                    foreach ($this->placedBlocksByClass[$cid] as $bid => $true) {
                        if ($bid !== $targetBid) {
                            $pb = $this->placedBlocks[$bid] ?? null;
                            if ($pb && empty($pb['is_retained']) && count($pb['slots']) === $size) {
                                if ($pb['day'] === $pbTarget['day'] && $pb['slots'] === $pbTarget['slots']) continue;
                                $candidates[] = $bid;
                            }
                        }
                    }
                }

                if (!empty($candidates)) {
                    $swapBid = $candidates[array_rand($candidates)];
                    $pbSwap = $this->placedBlocks[$swapBid];
                    $blockSwap = $this->findBlockById($swapBid);

                    $origDayA = $pbTarget['day'];
                    $origSlotsA = $pbTarget['slots'];
                    $origDayB = $pbSwap['day'];
                    $origSlotsB = $pbSwap['slots'];

                    // Same classroom, same size swap
                    $newSlotsA = $origSlotsB;
                    $newSlotsB = $origSlotsA;

                    // Jalankan pertukaran (swap)
                    $this->removeBlock($targetBid);
                    $this->removeBlock($swapBid);
                    $this->placeBlock($blockTarget, $origDayB, $newSlotsA);
                    $this->placeBlock($blockSwap, $origDayA, $newSlotsB);

                    $newPenalty = $this->calculateTotalPenalty(); // Tanpa parameter conflicted array (cepat!)
                    $delta = $newPenalty - $currentPenalty;

                    $accepted = false;
                    if ($delta < 0) {
                        $accepted = true;
                    } elseif ($delta > 0 && $temp > 0.1) {
                        $prob = exp(-$delta / $temp);
                        if ((mt_rand(0, 1000) / 1000.0) < $prob) {
                            $accepted = true;
                        }
                    } else {
                        // Delta = 0: terima 50% untuk sideways moves
                        $accepted = mt_rand(0, 1) === 1;
                    }

                    if ($accepted) {
                        $currentPenalty = $newPenalty;
                        $successfulSwaps++;

                        if ($currentPenalty < $bestPenalty) {
                            $bestPenalty = $currentPenalty;
                            $bestPlaced = $this->placedBlocks;
                            $bestPlacedBlocksByClass = $this->placedBlocksByClass;
                            $bestPlacedBlocksBySize = $this->placedBlocksBySize;
                            $bestTeacherSlot = $this->teacherSlot;
                            $bestClassSlot = $this->classSlot;
                            $bestDayCount = $this->dayCount;
                            $bestLoadDayMap = $this->loadDayMap;
                            $bestClassSubjectDay2 = $this->classSubjectDay2;
                            $bestAvailabilityConflictsCount = $this->availabilityConflictsCount;
                            $bestTeacherClashesCount = $this->teacherClashesCount;
                            $bestClassClashesCount = $this->classClashesCount;
                            $bestHc6OverHours = $this->hc6OverHours;
                            $bestHc7ConflictsCount = $this->hc7ConflictsCount;
                            $bestHc8ConflictsCount = $this->hc8ConflictsCount;
                            $bestLessonDistributionPenalty = $this->lessonDistributionPenalty;
                            $bestClassSubjectLoadCount = $this->classSubjectLoadCount;
                            $bestLoadLessonDistributionPenalty = $this->loadLessonDistributionPenalty;
                            $iterationsSinceImprovement = 0;
                        }
                    } else {
                        // Rollback swap
                        $this->removeBlock($targetBid);
                        $this->removeBlock($swapBid);
                        $this->placeBlock($blockTarget, $origDayA, $origSlotsA);
                        $this->placeBlock($blockSwap, $origDayB, $origSlotsB);
                    }
                }
            } else {
                $size = $blockTarget['size'];
                $tid = $blockTarget['teacher_id'];
                $cid = $blockTarget['class_id'];
                $origDay = $pbTarget['day'];
                $origSlots = $pbTarget['slots'];

                $newWindow = null;
                $randomDay = null;

                // Coba cari window yang kosong di kelas AND guru free
                $idealWindows = $this->getTeacherAndClassroomFreeWindows($tid, $cid, $size);
                if (!empty($idealWindows)) {
                    $chosen = $idealWindows[array_rand($idealWindows)];
                    $randomDay = $chosen['day'];
                    $newWindow = $chosen['slots'];
                } else {
                    // Fallback: cari window yang kosong di kelas (abaikan status guru)
                    $emptyWindows = $this->getClassroomEmptyWindows($cid, $size);
                    if (!empty($emptyWindows)) {
                        $chosen = $emptyWindows[array_rand($emptyWindows)];
                        $randomDay = $chosen['day'];
                        $newWindow = $chosen['slots'];
                    }
                }

                if ($newWindow !== null && $this->checkHC5Contiguous($randomDay, $newWindow)) {
                    // Jalankan perpindahan (move)
                    $this->removeBlock($targetBid);
                    $this->placeBlock($blockTarget, $randomDay, $newWindow);

                    $newPenalty = $this->calculateTotalPenalty(); // Tanpa parameter conflicted array (cepat!)
                    $delta = $newPenalty - $currentPenalty;

                    $accepted = false;
                    if ($delta < 0) {
                        $accepted = true;
                    } elseif ($delta > 0 && $temp > 0.1) {
                        $prob = exp(-$delta / $temp);
                        if ((mt_rand(0, 1000) / 1000.0) < $prob) {
                            $accepted = true;
                        }
                    } else {
                        $accepted = mt_rand(0, 1) === 1;
                    }

                    if ($accepted) {
                        $currentPenalty = $newPenalty;
                        $successfulMoves++;

                        if ($currentPenalty < $bestPenalty) {
                            $bestPenalty = $currentPenalty;
                            $bestPlaced = $this->placedBlocks;
                            $bestPlacedBlocksByClass = $this->placedBlocksByClass;
                            $bestPlacedBlocksBySize = $this->placedBlocksBySize;
                            $bestTeacherSlot = $this->teacherSlot;
                            $bestClassSlot = $this->classSlot;
                            $bestDayCount = $this->dayCount;
                            $bestLoadDayMap = $this->loadDayMap;
                            $bestClassSubjectDay2 = $this->classSubjectDay2;
                            $bestAvailabilityConflictsCount = $this->availabilityConflictsCount;
                            $bestTeacherClashesCount = $this->teacherClashesCount;
                            $bestClassClashesCount = $this->classClashesCount;
                            $bestHc6OverHours = $this->hc6OverHours;
                            $bestHc7ConflictsCount = $this->hc7ConflictsCount;
                            $bestHc8ConflictsCount = $this->hc8ConflictsCount;
                            $bestLessonDistributionPenalty = $this->lessonDistributionPenalty;
                            $bestClassSubjectLoadCount = $this->classSubjectLoadCount;
                            $bestLoadLessonDistributionPenalty = $this->loadLessonDistributionPenalty;
                            $iterationsSinceImprovement = 0;
                        }
                    } else {
                        // Rollback move
                        $this->removeBlock($targetBid);
                        $this->placeBlock($blockTarget, $origDay, $origSlots);
                    }
                }
            }
        }

        // Terapkan hasil terbaik yang pernah ditemukan selama iterasi
        $this->placedBlocks = $bestPlaced;
        $this->placedBlocksByClass = $bestPlacedBlocksByClass;
        $this->placedBlocksBySize = $bestPlacedBlocksBySize;
        $this->teacherSlot = $bestTeacherSlot;
        $this->classSlot = $bestClassSlot;
        $this->dayCount = $bestDayCount;
        $this->loadDayMap = $bestLoadDayMap;
        $this->classSubjectDay2 = $bestClassSubjectDay2;
        $this->availabilityConflictsCount = $bestAvailabilityConflictsCount;
        $this->teacherClashesCount = $bestTeacherClashesCount;
        $this->classClashesCount = $bestClassClashesCount;
        $this->hc6OverHours = $bestHc6OverHours;
        $this->hc7ConflictsCount = $bestHc7ConflictsCount;
        $this->hc8ConflictsCount = $bestHc8ConflictsCount;
        $this->lessonDistributionPenalty = $bestLessonDistributionPenalty;
        $this->classSubjectLoadCount = $bestClassSubjectLoadCount;
        $this->loadLessonDistributionPenalty = $bestLoadLessonDistributionPenalty;

        $finalSlowPenalty = $this->calculateTotalPenalty($conflicted);
        $finalFastPenalty = $this->calculateTotalPenalty();
        $this->logs[] = "  [DEBUG] Final Slow Penalty: {$finalSlowPenalty} | Fast Penalty: {$finalFastPenalty}";
        if ($finalSlowPenalty !== $finalFastPenalty) {
            $slowTeacherClashes = 0;
            foreach ($this->teacherSlot as $tId => $days) {
                foreach ($days as $day => $slots) {
                    foreach ($slots as $slot => $bids) {
                        $cnt = count($bids);
                        if ($cnt > 1) $slowTeacherClashes += ($cnt - 1);
                    }
                }
            }
            $slowClassClashes = 0;
            foreach ($this->classSlot as $cId => $days) {
                foreach ($days as $day => $slots) {
                    foreach ($slots as $slot => $bids) {
                        $cnt = count($bids);
                        if ($cnt > 1) $slowClassClashes += ($cnt - 1);
                    }
                }
            }
            $slowHC6 = 0;
            foreach ($this->dayCount as $tId => $days) {
                $maxHours = $this->teacherMaxHoursCache[$tId] ?? $this->globalMaxHours;
                foreach ($days as $day => $hours) {
                    if ($hours > $maxHours) {
                        $slowHC6 += ($hours - $maxHours);
                    }
                }
            }
            $slowHC7 = 0;
            foreach ($this->classSubjectDay2 as $cid => $days) {
                foreach ($days as $day => $subjs) {
                    foreach ($subjs as $sid => $bids) {
                        $loads = [];
                        foreach (array_keys($bids) as $bid) {
                            $pb = $this->placedBlocks[$bid] ?? null;
                            if ($pb) {
                                $loads[$pb['load_id']] = true;
                            }
                        }
                        $cnt = count($loads);
                        if ($cnt > 1) $slowHC7 += ($cnt - 1);
                    }
                }
            }
            $slowHC8 = 0;
            $slowLD = 0;
            foreach ($this->loadDayMap as $lid => $days) {
                $uniqueDays = [];
                foreach ($days as $day => $bids) {
                    $cnt = count($bids);
                    if ($cnt > 0) {
                        $uniqueDays[] = $day;
                    }
                    if ($cnt > 1) {
                        $slowHC8 += ($cnt - 1);
                    }
                }
                if (count($uniqueDays) > 1) {
                    sort($uniqueDays);
                    for ($idx = 0; $idx < count($uniqueDays) - 1; $idx++) {
                        if ($uniqueDays[$idx + 1] - $uniqueDays[$idx] === 1) {
                            $slowLD += 20;
                        }
                    }
                }
            }

            $this->logs[] = "  [DEBUG] MISMATCH DETAIL AT END:";
            $this->logs[] = "    Teacher Clashes: {$slowTeacherClashes} vs {$this->teacherClashesCount}";
            $this->logs[] = "    Class Clashes: {$slowClassClashes} vs {$this->classClashesCount}";
            $this->logs[] = "    HC6 Over Hours: {$slowHC6} vs {$this->hc6OverHours}";
            $this->logs[] = "    HC7 Conflicts: {$slowHC7} vs {$this->hc7ConflictsCount}";
            $this->logs[] = "    HC8 Conflicts: {$slowHC8} vs {$this->hc8ConflictsCount}";
            $this->logs[] = "    Lesson Distribution: {$slowLD} vs {$this->lessonDistributionPenalty}";
        }

        $this->logs[] = "  Selesai Optimasi - Penalti Akhir: {$bestPenalty} (Swaps: {$successfulSwaps}, Moves: {$successfulMoves}, Iterations: {$iter})";
    }

    // ══════════════════════════════════════════════════════════════════
    // STATE MANAGEMENT
    // ══════════════════════════════════════════════════════════════════

    /**
     * Tempatkan block ke (day, slots) dan update semua state array.
     * teacherSlot dan classSlot menyimpan block_id sebagai value
     * sehingga findObstructors bisa langsung menemukan block mana yang menghalangi.
     */
    protected function placeBlock(array $block, int $day, array $slots): void
    {
        $bid = $block['block_id'];
        $tid = $block['teacher_id'];
        $cid = $block['class_id'];
        $sid = $block['subject_id'];
        $lid = $block['load_id'];

        $this->placedBlocks[$bid] = [
            'block_id'   => $bid,
            'load_id'    => $lid,
            'teacher_id' => $tid,
            'class_id'   => $cid,
            'subject_id' => $sid,
            'day'        => $day,
            'slots'      => $slots,
        ];
        $this->placedBlocksByClass[$cid][$bid] = true;
        $this->placedBlocksBySize[count($slots)][$bid] = true;

        foreach ($slots as $slot) {
            $oldTeacherCount = isset($this->teacherSlot[$tid][$day][$slot]) ? count($this->teacherSlot[$tid][$day][$slot]) : 0;
            $this->teacherSlot[$tid][$day][$slot][$bid] = true;
            $newTeacherCount = count($this->teacherSlot[$tid][$day][$slot]);
            if ($oldTeacherCount >= 1) {
                $this->teacherClashesCount += ($newTeacherCount - $oldTeacherCount);
            }

            $oldClassCount = isset($this->classSlot[$cid][$day][$slot]) ? count($this->classSlot[$cid][$day][$slot]) : 0;
            $this->classSlot[$cid][$day][$slot][$bid]   = true;
            $newClassCount = count($this->classSlot[$cid][$day][$slot]);
            if ($oldClassCount >= 1) {
                $this->classClashesCount += ($newClassCount - $oldClassCount);
            }

            if ($this->isBlocked($tid, $day, $slot)) {
                $this->availabilityConflictsCount++;
            }
        }

        $maxHours = $this->teacherMaxHoursCache[$tid] ?? $this->globalMaxHours;
        $oldHours = $this->dayCount[$tid][$day] ?? 0;
        $this->dayCount[$tid][$day] = $oldHours + count($slots);
        $newHours = $this->dayCount[$tid][$day];

        $oldExceeded = max(0, $oldHours - $maxHours);
        $newExceeded = max(0, $newHours - $maxHours);
        $this->hc6OverHours += ($newExceeded - $oldExceeded);

        // HC8 cache & penalty
        $oldBlocksCount = isset($this->loadDayMap[$lid][$day]) ? count($this->loadDayMap[$lid][$day]) : 0;
        $oldViolations = max(0, $oldBlocksCount - 1);

        $this->loadDayMap[$lid][$day][$bid] = true;
        $newBlocksCount = count($this->loadDayMap[$lid][$day]);
        $newViolations = max(0, $newBlocksCount - 1);

        $this->hc8ConflictsCount += ($newViolations - $oldViolations);

        // Lesson Distribution
        $oldPen = $this->loadLessonDistributionPenalty[$lid] ?? 0;
        $newPen = $this->calculateLoadLessonDistributionPenalty($lid);
        $this->loadLessonDistributionPenalty[$lid] = $newPen;
        $this->lessonDistributionPenalty += ($newPen - $oldPen);

        // HC7 cache & penalty
        $oldDistinct = isset($this->classSubjectLoadCount[$cid][$day][$sid]) ? count($this->classSubjectLoadCount[$cid][$day][$sid]) : 0;
        $oldViolationsHC7 = max(0, $oldDistinct - 1);
        
        $this->classSubjectLoadCount[$cid][$day][$sid][$lid] = ($this->classSubjectLoadCount[$cid][$day][$sid][$lid] ?? 0) + 1;
        $newDistinct = count($this->classSubjectLoadCount[$cid][$day][$sid]);
        $newViolationsHC7 = max(0, $newDistinct - 1);
        
        $this->hc7ConflictsCount += ($newViolationsHC7 - $oldViolationsHC7);
        $this->classSubjectDay2[$cid][$day][$sid][$bid] = true;
    }

    /**
     * Lepas block dari state. Tidak menghapus dari allBlocks.
     */
    protected function removeBlock(int $blockId): void
    {
        $pb = $this->placedBlocks[$blockId] ?? null;
        if ($pb === null) return;

        $tid = $pb['teacher_id'];
        $cid = $pb['class_id'];
        $sid = $pb['subject_id'];
        $lid = $pb['load_id'];
        $day = $pb['day'];
        $size = count($pb['slots']);
        unset($this->placedBlocksByClass[$cid][$blockId]);
        if (empty($this->placedBlocksByClass[$cid])) {
            unset($this->placedBlocksByClass[$cid]);
        }
        unset($this->placedBlocksBySize[$size][$blockId]);
        if (empty($this->placedBlocksBySize[$size])) {
            unset($this->placedBlocksBySize[$size]);
        }

        foreach ($pb['slots'] as $slot) {
            $oldTeacherCount = count($this->teacherSlot[$tid][$day][$slot] ?? []);
            unset($this->teacherSlot[$tid][$day][$slot][$blockId]);
            $newTeacherCount = count($this->teacherSlot[$tid][$day][$slot] ?? []);
            if ($oldTeacherCount > 1) {
                $this->teacherClashesCount -= ($oldTeacherCount - $newTeacherCount);
            }
            if (empty($this->teacherSlot[$tid][$day][$slot])) {
                unset($this->teacherSlot[$tid][$day][$slot]);
            }

            $oldClassCount = count($this->classSlot[$cid][$day][$slot] ?? []);
            unset($this->classSlot[$cid][$day][$slot][$blockId]);
            $newClassCount = count($this->classSlot[$cid][$day][$slot] ?? []);
            if ($oldClassCount > 1) {
                $this->classClashesCount -= ($oldClassCount - $newClassCount);
            }
            if (empty($this->classSlot[$cid][$day][$slot])) {
                unset($this->classSlot[$cid][$day][$slot]);
            }

            if ($this->isBlocked($tid, $day, $slot)) {
                $this->availabilityConflictsCount--;
            }
        }

        $maxHours = $this->teacherMaxHoursCache[$tid] ?? $this->globalMaxHours;
        $oldHours = $this->dayCount[$tid][$day] ?? 0;
        $this->dayCount[$tid][$day] = max(0, $oldHours - count($pb['slots']));
        $newHours = $this->dayCount[$tid][$day];

        $oldExceeded = max(0, $oldHours - $maxHours);
        $newExceeded = max(0, $newHours - $maxHours);
        $this->hc6OverHours += ($newExceeded - $oldExceeded);

        // HC8 cache & penalty
        $oldBlocksCount = count($this->loadDayMap[$lid][$day] ?? []);
        $oldViolations = max(0, $oldBlocksCount - 1);

        unset($this->loadDayMap[$lid][$day][$blockId]);
        $newBlocksCount = count($this->loadDayMap[$lid][$day] ?? []);
        if ($newBlocksCount <= 0) {
            unset($this->loadDayMap[$lid][$day]);
        }
        $newViolations = max(0, $newBlocksCount - 1);

        $this->hc8ConflictsCount += ($newViolations - $oldViolations);

        // Lesson Distribution
        $oldPen = $this->loadLessonDistributionPenalty[$lid] ?? 0;
        $newPen = $this->calculateLoadLessonDistributionPenalty($lid);
        $this->loadLessonDistributionPenalty[$lid] = $newPen;
        $this->lessonDistributionPenalty += ($newPen - $oldPen);

        // HC7 cache & penalty
        $oldDistinct = count($this->classSubjectLoadCount[$cid][$day][$sid] ?? []);
        $oldViolationsHC7 = max(0, $oldDistinct - 1);
        
        if (isset($this->classSubjectLoadCount[$cid][$day][$sid][$lid])) {
            $this->classSubjectLoadCount[$cid][$day][$sid][$lid]--;
            if ($this->classSubjectLoadCount[$cid][$day][$sid][$lid] <= 0) {
                unset($this->classSubjectLoadCount[$cid][$day][$sid][$lid]);
            }
        }
        $newDistinct = count($this->classSubjectLoadCount[$cid][$day][$sid] ?? []);
        $newViolationsHC7 = max(0, $newDistinct - 1);
        
        $this->hc7ConflictsCount += ($newViolationsHC7 - $oldViolationsHC7);

        unset($this->classSubjectDay2[$cid][$day][$sid][$blockId]);
        if (empty($this->classSubjectDay2[$cid][$day][$sid])) {
            unset($this->classSubjectDay2[$cid][$day][$sid]);
        }

        unset($this->placedBlocks[$blockId]);
    }

    /**
     * Cek apakah semua slot di window bebas untuk teacher dan class.
     *
     * HC1: guru tidak ada di slot ini
     * HC2: kelas tidak ada di slot ini
     * HC3: guru tidak diblokir
     * HC4: slot harus KBM
     * HC5: kontigu — dicek di findBestWindow (window harus KBM berurutan
     *      boleh melompati non-KBM, tidak boleh melompati KBM lain)
     */
    protected function isWindowFree(
        int $teacherId,
        int $classId,
        int $day,
        array $window
    ): bool
    {
        if (!$this->checkHC5Contiguous($day, $window)) {
            return false;
        }

        foreach ($window as $slot) {

            if (($this->allSlotTypes[$day][$slot] ?? '') !== 'kbm') {
                return false;
            }

            if ($this->isBlocked($teacherId, $day, $slot)) {
                return false;
            }

            if (isset($this->teacherSlot[$teacherId][$day][$slot])) {
                return false;
            }

            if (isset($this->classSlot[$classId][$day][$slot])) {
                return false;
            }
        }

        return true;
    }

    /**
     * Cek HC6 (SOFT CONSTRAINT): apakah guru melebihi max_hours_per_day.
     *
     * Berbeda dari ASc:
     * - Placement phase: HC6 dihormati (hard) agar distribusi merata
     * - Repair phase last-resort: HC6 boleh dilanggar dengan penalty skor
     *   Ini memungkinkan blok yang tidak bisa masuk manapun tetap bisa
     *   ditempatkan, dengan trade-off guru punya lebih dari max jam di satu hari.
     *
     * @param bool $strict  true = hard (tolak jika melebihi), false = soft (boleh melebihi)
     */
    protected function checkHC6(int $teacherId, int $day, int $blockSize, bool $strict = true): bool
    {
        $maxHours   = $this->teacherMaxHoursCache[$teacherId] ?? $this->globalMaxHours;
        $currentDay = $this->dayCount[$teacherId][$day] ?? 0;

        if ($strict) {
            return ($currentDay + $blockSize) <= $maxHours;
        }

        // Soft mode: selalu true, tapi catat pelanggaran untuk log/warning
        return true;
    }

    /**
     * Hitung penalty HC6 untuk scoring (dipakai di findBestWindow soft mode)
     */
    protected function hc6Penalty(int $teacherId, int $day, int $blockSize): int
    {
        $maxHours   = $this->teacherMaxHoursCache[$teacherId] ?? $this->globalMaxHours;
        $currentDay = $this->dayCount[$teacherId][$day] ?? 0;
        $overflow   = max(0, ($currentDay + $blockSize) - $maxHours);
        return $overflow * $this->hc6SoftPenalty;
    }

    /**
     * HC7: mapel yang sama tidak boleh 2x dalam satu hari untuk satu kelas.
     * PENTING: Ini hanya relevan untuk load BERBEDA dengan mapel yang sama.
     * Blok dari load yang sama sudah dijamin di hari berbeda oleh HC8.
     * Guru mengajar mapel sama di kelas BERBEDA di hari yang sama = BOLEH.
     * Kelas yang sama mapel sama di hari yang sama dari load berbeda = TIDAK BOLEH.
     */
    protected function checkHC7(int $classId, int $subjectId, int $day, int $loadId = 0): bool
    {
        if (empty($this->classSubjectLoadCount[$classId][$day][$subjectId])) return true;

        if ($loadId === 0) return false; // tidak ada info load → tolak untuk aman

        $loadCount = count($this->classSubjectLoadCount[$classId][$day][$subjectId]);
        if ($loadCount === 0) return true;

        // Jika hanya ada 1 load, dan load itu adalah loadId kita, maka OK
        if ($loadCount === 1 && isset($this->classSubjectLoadCount[$classId][$day][$subjectId][$loadId])) {
            return true;
        }

        // Jika ada load lain selain loadId ini, maka ada bentrok HC7
        return false;
    }

    /**
     * HC8: blok dari load yang sama tidak boleh di hari yang sama — O(1)
     */
    protected function checkHC8(int $loadId, int $day): bool
    {
        return empty($this->loadDayMap[$loadId][$day]);
    }

    /**
     * Cek kontigu HC5 yang benar:
     * Antara slot KBM pertama dan terakhir di window,
     * tidak boleh ada slot KBM lain yang TIDAK termasuk window.
     */
    protected function checkHC5Contiguous(int $day, array $window): bool
    {
        if (count($window) <= 1) return true;
        $first = min($window);
        $last  = max($window);
        // Semua slot di antara first dan last
        foreach ($this->allSlotTypes[$day] as $slotNum => $type) {
            if ($slotNum <= $first || $slotNum >= $last) continue;
            // Jika ada slot KBM di antara yang tidak termasuk window → tidak kontigu
            if ($type === 'kbm' && !in_array($slotNum, $window, true)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Cari block dari allBlocks berdasarkan block_id — O(1) via blockIndex.
     */
    protected function findBlockById(int $blockId): ?array
    {
        return $this->blockIndex[$blockId] ?? null;
    }

    // ══════════════════════════════════════════════════════════════════
    // LOAD RETAINED ENTRIES
    // ══════════════════════════════════════════════════════════════════

    /**
     * Muat entri locked/manual dari DB ke state.
     * Block id untuk retained entry menggunakan negative index
     * agar tidak bentrok dengan block_id dari allBlocks.
     */
    protected function loadRetainedEntries(int $scheduleId, bool $keepManual): void
    {
        $builder = $this->entryModel->db->table('schedule_entries')
            ->where('schedule_id', $scheduleId)
            ->where('is_locked', 1);

        if ($keepManual) {
            $builder->orWhere('is_manual', 1);
        }

        // Group by load_id + day agar slot berurutan menjadi satu block
        $rows    = $builder->get()->getResultArray();
        $grouped = [];

        foreach ($rows as $row) {
            $key            = $row['schedule_load_id'] . '_' . $row['day'];
            $grouped[$key][] = $row;
        }

        $retainedId = -1;

        foreach ($grouped as $group) {
            $first = $group[0];
            $slots = array_map(fn($r) => (int)$r['slot_number'], $group);
            sort($slots);

            $bid = $retainedId--;

            // Daftarkan ke allBlocks agar findBlockById bisa menemukannya
            $retainedBlock = [
                'block_id'   => $bid,
                'load_id'    => $first['schedule_load_id'],
                'teacher_id' => (int)$first['teacher_id'],
                'class_id'   => (int)$first['classroom_id'],
                'subject_id' => (int)$first['school_subject_id'],
                'size'       => count($slots),
                'difficulty' => 0,
            ];
            $this->allBlocks[]      = $retainedBlock;
            $this->blockIndex[$bid] = $retainedBlock;

            // Gunakan placeBlock untuk inisialisasi state & cache dengan benar
            $this->placeBlock($retainedBlock, (int)$first['day'], $slots);
            $this->placedBlocks[$bid]['is_retained'] = true;
        }
    }

    // ══════════════════════════════════════════════════════════════════
    // PRELOAD TEACHER DATA
    // ══════════════════════════════════════════════════════════════════

    protected function preloadTeacherData(): void
    {
        // Ambil setting per guru — gunakan isset() agar NULL dibedakan dari 0
        $customMap = [];
        foreach ($this->settingModel->findAll() as $s) {
            $val = $s['max_hours_per_day'];
            if ($val !== null && (int)$val > 0) {
                $customMap[(int)$s['teacher_id']] = (int)$val;
            }
        }

        $teachers = $this->loadModel->db->table('teachers')
            ->select('id')
            ->get()
            ->getResultArray();

        foreach ($teachers as $t) {
            $tid = (int)$t['id'];

            // Kalau guru punya setting custom → pakai custom (TIDAK di-cap ke global)
            // Kalau tidak ada setting → pakai global
            $this->teacherMaxHoursCache[$tid] = isset($customMap[$tid])
                ? $customMap[$tid]
                : $this->globalMaxHours;
        }
    }

    // ══════════════════════════════════════════════════════════════════
    // HITUNG KONFLIK
    // ══════════════════════════════════════════════════════════════════

    protected function calculateConflicts(array $loads): array
    {
        // Hitung slot terpasang per load_id
        $placedPerLoad = [];
        foreach ($this->placedBlocks as $pb) {
            $placedPerLoad[$pb['load_id']] =
                ($placedPerLoad[$pb['load_id']] ?? 0) + count($pb['slots']);
        }

        // Hitung kebutuhan per load_id
        $neededPerLoad = [];
        foreach ($this->allBlocks as $block) {
            if ($block['block_id'] < 0) continue; // skip retained
            $neededPerLoad[$block['load_id']] =
                ($neededPerLoad[$block['load_id']] ?? 0) + $block['size'];
        }

        $conflicts = [];
        foreach ($neededPerLoad as $lid => $needed) {
            $placed = $placedPerLoad[$lid] ?? 0;
            if ($placed < $needed) {
                $load = current(array_filter($loads, fn($l) => $l['id'] == $lid));
                if ($load) {
                    $conflicts[] = [
                        'load_id'        => $lid,
                        'teacher_name'   => $load['teacher_name'],
                        'subject_name'   => $load['subject_name'],
                        'classroom_name' => $load['classroom_name'],
                        'needed'         => $needed,
                        'placed'         => $placed,
                        'unplaced'       => $needed - $placed,
                    ];
                }
            }
        }

        return $conflicts;
    }

    // ══════════════════════════════════════════════════════════════════
    // LOAD DATA
    // ══════════════════════════════════════════════════════════════════

    protected function loadKbmSlots(): void
    {
        $slots = $this->timeslotModel->getActiveKbmSlots();
        foreach ($slots as $slot) {
            $day = (int)$slot['day'];
            $num = (int)$slot['slot_number'];
            if (in_array($day, $this->activeDays, true)) {
                $this->kbmSlots[$day][$num] = $slot;
            }
        }
    }

    protected function loadAllSlotTypes(): void
    {
        $allSlots = $this->timeslotModel
            ->whereIn('day', $this->activeDays)
            ->where('is_active', 1)
            ->orderBy('day')
            ->orderBy('slot_number')
            ->findAll();

        foreach ($allSlots as $slot) {
            $this->allSlotTypes[(int)$slot['day']][(int)$slot['slot_number']] = $slot['slot_type'];
        }
    }

    protected function loadBlockedMap(): void
    {
        $rows = $this->availModel->getAllBlocked();
        foreach ($rows as $row) {
            $this->blockedMap[(int)$row['teacher_id']][(int)$row['day']][(int)$row['slot_number']] = true;
        }
    }

    protected function deleteNonLockedGenerated(int $scheduleId, bool $keepManual): void
    {
        $builder = $this->entryModel->db->table('schedule_entries')
            ->where('schedule_id', $scheduleId)
            ->where('is_locked', 0);

        if ($keepManual) {
            $builder->where('is_manual', 0);
        }

        $builder->delete();
    }

    // ══════════════════════════════════════════════════════════════════
    // HELPER
    // ══════════════════════════════════════════════════════════════════

    protected function isBlocked(int $teacherId, int $day, int $slot): bool
    {
        return isset($this->blockedMap[$teacherId][$day][0])
            || isset($this->blockedMap[$teacherId][$day][$slot]);
    }

    /**
     * Pre-calculate all valid contiguous windows of size 1, 2, and 3 for active days.
     * This avoids expensive array sorting and contiguous checks on every SA iteration.
     */
    protected function precalculateValidWindows(): void
    {
        $this->validWindowsCache = [];
        for ($size = 1; $size <= 3; $size++) {
            foreach ($this->activeDays as $day) {
                $kbmNums = array_keys($this->kbmSlots[$day] ?? []);
                sort($kbmNums);
                $nKbm = count($kbmNums);
                if ($nKbm < $size) continue;

                for ($i = 0; $i <= $nKbm - $size; $i++) {
                    $window = array_slice($kbmNums, $i, $size);
                    if ($this->checkHC5Contiguous($day, $window)) {
                        $this->validWindowsCache[$size][$day][] = $window;
                    }
                }
            }
        }
    }

    /**
     * Get all windows of size $size on active days where classroom $classId is completely empty.
     */
    protected function getClassroomEmptyWindows(int $classId, int $size): array
    {
        $windows = [];
        foreach ($this->activeDays as $day) {
            $candidates = $this->validWindowsCache[$size][$day] ?? [];
            foreach ($candidates as $window) {
                $allEmpty = true;
                foreach ($window as $slot) {
                    if (!empty($this->classSlot[$classId][$day][$slot])) {
                        $allEmpty = false;
                        break;
                    }
                }
                if ($allEmpty) {
                    $windows[] = ['day' => $day, 'slots' => $window];
                }
            }
        }
        return $windows;
    }

    /**
     * Get all windows of size $size on active days where teacher $teacherId is completely free
     * (not blocked and not teaching any other class).
     */
    protected function getTeacherFreeWindows(int $teacherId, int $size): array
    {
        $windows = [];
        foreach ($this->activeDays as $day) {
            $candidates = $this->validWindowsCache[$size][$day] ?? [];
            foreach ($candidates as $window) {
                $allFree = true;
                foreach ($window as $slot) {
                    if ($this->isBlocked($teacherId, $day, $slot) || !empty($this->teacherSlot[$teacherId][$day][$slot])) {
                        $allFree = false;
                        break;
                    }
                }
                if ($allFree) {
                    $windows[] = ['day' => $day, 'slots' => $window];
                }
            }
        }
        return $windows;
    }

    /**
     * Get all windows of size $size on active days where teacher is free AND classroom is empty.
     */
    protected function getTeacherAndClassroomFreeWindows(int $teacherId, int $classId, int $size): array
    {
        $windows = [];
        foreach ($this->activeDays as $day) {
            $candidates = $this->validWindowsCache[$size][$day] ?? [];
            foreach ($candidates as $window) {
                $ok = true;
                foreach ($window as $slot) {
                    if ($this->isBlocked($teacherId, $day, $slot) || !empty($this->teacherSlot[$teacherId][$day][$slot])) {
                        $ok = false;
                        break;
                    }
                    if (!empty($this->classSlot[$classId][$day][$slot])) {
                        $ok = false;
                        break;
                    }
                }
                if ($ok) {
                    $windows[] = ['day' => $day, 'slots' => $window];
                }
            }
        }
        return $windows;
    }

    protected function parseSplitConfig(int $totalHours, string $splitConfig): array
    {
        if (empty(trim($splitConfig))) return [$totalHours];

        $blocks = array_filter(
            array_map('intval', explode(',', $splitConfig)),
            fn($b) => $b > 0
        );

        if (empty($blocks) || array_sum($blocks) !== $totalHours) {
            return [$totalHours];
        }

        return array_values(array_slice($blocks, 0, 3));
    }

    protected function buildResponse(int $placed, int $total, array $conflicts, array $logs): array
    {
        return [
            'success'   => empty($conflicts),
            'placed'    => $placed,
            'total'     => $total,
            'conflicts' => $conflicts,
            'log'       => $logs,
        ];
    }

    protected function reset(): void
    {
        $this->kbmSlots              = [];
        $this->allSlotTypes          = [];
        $this->activeDays            = [];
        $this->blockedMap            = [];
        $this->teacherMaxHoursCache  = [];
        $this->allBlocks             = [];
        $this->blockIndex            = [];
        $this->placedBlocks          = [];
        $this->placedBlocksByClass = [];
        $this->placedBlocksBySize = [];
        $this->teacherSlot           = [];
        $this->classSlot             = [];
        $this->dayCount              = [];
        $this->loadDayMap            = [];
        $this->classSubjectDay2      = [];
        $this->availabilityConflictsCount = 0;
        $this->validWindowsCache = [];
        $this->logs                  = [];
        $this->timePlacement         = 0;
        $this->timeRepair            = 0;
        $this->timeSwap              = 0;
        $this->timeTotal             = 0;
        $this->teacherClashesCount = 0;
        $this->classClashesCount = 0;
        $this->hc6OverHours = 0;
        $this->hc7ConflictsCount = 0;
        $this->hc8ConflictsCount = 0;
        $this->lessonDistributionPenalty = 0;
        $this->classSubjectLoadCount = [];
        $this->loadLessonDistributionPenalty = [];
    }

    // ==================================================================
// GENERATE BERDASARKAN UKURAN BLOK (3, 2, atau 1)
// ==================================================================

public function generateByBlockSize(
    int    $scheduleId,
    int    $yearId,
    int    $periodId,
    string $activeDays = '1,2,3,4,5',
    int    $blockSizeFilter = 3,  // 3, 2, atau 1
    bool   $keepManual = false,
    bool   $resetFirst = false
): array {
    $this->reset();
    $this->timeTotal = microtime(true);
    $this->globalMaxHours = $this->settingModel->getGlobalMaxHours();

    // Reset jika diminta (hanya untuk blok 3 jam)
    if ($resetFirst) {
        $this->deleteNonLockedGenerated($scheduleId, $keepManual);
    }

    // Siapkan data dasar
    $this->activeDays = array_map('intval', explode(',', $activeDays));
    $this->loadKbmSlots();
    $this->loadAllSlotTypes();
    $this->loadBlockedMap();
    $this->preloadTeacherData();

    // Load entri yang sudah ada (retained)
    $this->loadRetainedEntries($scheduleId, $keepManual);
    
    // Load entri generated yang sudah ada (untuk dipertahankan)
    $this->loadExistingGeneratedEntries($scheduleId);

    // Ambil beban mengajar
    $loads = $this->loadModel->getForGenerator($yearId, $periodId);
    if (empty($loads)) {
        return $this->buildResponse(0, 0, [], ['Tidak ada beban mengajar']);
    }

    // Bangun blok dan filter berdasarkan ukuran
    $this->buildBlocksFiltered($loads, $blockSizeFilter);

    if (empty($this->allBlocks)) {
        return $this->buildResponse(0, 0, [], ["Tidak ada blok ukuran {$blockSizeFilter}"]);
    }

    // Urutkan berdasarkan difficulty
    usort($this->allBlocks, fn($a, $b) => $b['difficulty'] <=> $a['difficulty']);

    $totalJam = array_sum(array_column($this->allBlocks, 'size'));
    $this->logs[] = "Generate blok ukuran: {$blockSizeFilter}";
    $this->logs[] = "Total blok   : " . count($this->allBlocks);
    $this->logs[] = "Total jam    : {$totalJam}";

    // Placement phase untuk blok yang difilter
    $failedBlocks = $this->placementPhase($this->allBlocks);

    // Simpan ke database
    $batchInsert = [];
    $totalPlaced = 0;

    foreach ($this->placedBlocks as $pb) {
        if (!empty($pb['is_retained'])) {
            $totalPlaced += count($pb['slots']);
            continue;
        }
        // Hanya simpan yang ukurannya sesuai filter
        if (count($pb['slots']) != $blockSizeFilter) continue;
        
        foreach ($pb['slots'] as $slot) {
            $batchInsert[] = [
                'schedule_id'       => $scheduleId,
                'schedule_load_id'  => $pb['load_id'],
                'teacher_id'        => $pb['teacher_id'],
                'classroom_id'      => $pb['class_id'],
                'school_subject_id' => $pb['subject_id'],
                'day'               => $pb['day'],
                'slot_number'       => $slot,
                'timeslot_id'       => $this->kbmSlots[$pb['day']][$slot]['id'],
                'is_manual'         => 0,
                'is_locked'         => 0,
                'created_at'        => date('Y-m-d H:i:s'),
                'updated_at'        => date('Y-m-d H:i:s'),
            ];
            $totalPlaced++;
        }
    }

    if (!empty($batchInsert)) {
        $this->entryModel->db->table('schedule_entries')->insertBatch($batchInsert);
    }

    $percentage = $totalJam > 0 ? round($totalPlaced / $totalJam * 100) : 0;
    $this->logs[] = "Selesai: {$totalPlaced} / {$totalJam} jam ({$percentage}%)";

    return [
        'success' => true,
        'placed' => $totalPlaced,
        'total' => $totalJam,
        'log' => $this->logs,
    ];
}

/**
 * Load existing generated entries (bukan retained)
 */
protected function loadExistingGeneratedEntries(int $scheduleId): void
{
    $entries = $this->entryModel->db->table('schedule_entries')
        ->where('schedule_id', $scheduleId)
        ->where('is_locked', 0)
        ->where('is_manual', 0)
        ->get()
        ->getResultArray();

    // Kelompokkan per load_id + day + slots (deteksi block)
    $grouped = [];
    foreach ($entries as $e) {
        $key = $e['schedule_load_id'] . '_' . $e['day'];
        if (!isset($grouped[$key])) {
            $grouped[$key] = [];
        }
        $grouped[$key][] = $e;
    }

    $nextBlockId = 10000;

    foreach ($grouped as $group) {
        if (empty($group)) continue;
        
        $first = $group[0];
        $slots = array_map(fn($r) => (int)$r['slot_number'], $group);
        sort($slots);
        
        $bid = $nextBlockId++;
        
        // Cek apakah block ini sudah ada di placedBlocks (hindari duplikat)
        if (isset($this->placedBlocks[$bid])) continue;
        
        $existingBlock = [
            'block_id'   => $bid,
            'load_id'    => $first['schedule_load_id'],
            'teacher_id' => (int)$first['teacher_id'],
            'class_id'   => (int)$first['classroom_id'],
            'subject_id' => (int)$first['school_subject_id'],
            'size'       => count($slots),
            'difficulty' => 0,
        ];
        // Daftarkan ke allBlocks
        $this->allBlocks[]      = $existingBlock;
        $this->blockIndex[$bid] = $existingBlock;

        $this->placeBlock($existingBlock, (int)$first['day'], $slots);
    }
}

/**
 * Bangun blok hanya untuk ukuran tertentu
 */
protected function buildBlocksFiltered(array $loads, int $blockSizeFilter): void
{
    $blockId = 100000; // ID besar untuk generated blocks

    foreach ($loads as $load) {
        $teacherId  = (int)$load['teacher_id'];
        $totalHours = (int)$load['hours_per_week'];
        $sizes      = $this->parseSplitConfig($totalHours, $load['split_config'] ?? '');
        rsort($sizes);

        foreach ($sizes as $size) {
            if ($size != $blockSizeFilter) continue;
            
            $windows    = $this->countContigousWindows($teacherId, $size);
            $difficulty = $size / max(1, $windows) * 100;

            $block = [
                'block_id'   => $blockId,
                'load_id'    => $load['id'],
                'teacher_id' => $teacherId,
                'class_id'   => (int)$load['classroom_id'],
                'subject_id' => (int)$load['school_subject_id'],
                'size'       => $size,
                'difficulty' => $difficulty,
            ];

            $this->allBlocks[]          = $block;
            $this->blockIndex[$blockId] = $block;
            $blockId++;
        }
    }
}


// ==================================================================
// HITUNG PELUANG BLOK 3 JAM
// ==================================================================

public function checkThreeHourOpportunity(int $scheduleId, int $yearId, int $periodId, string $activeDays): array
{
    $this->reset();
    $this->globalMaxHours = $this->settingModel->getGlobalMaxHours();
    $this->activeDays = array_map('intval', explode(',', $activeDays));
    $this->loadKbmSlots();
    $this->loadAllSlotTypes();
    $this->loadBlockedMap();
    $this->preloadTeacherData();

    // Load existing entries
    $this->loadAllEntriesFromDatabase($scheduleId);
    $this->loadRetainedEntries($scheduleId, false);

    // Hitung kebutuhan blok 3 jam dari beban mengajar
    $loads = $this->loadModel->getForGenerator($yearId, $periodId);
    $threeHourNeeded = 0;
    foreach ($loads as $load) {
        $blocks = $this->parseSplitConfig($load['hours_per_week'], $load['split_config'] ?? '');
        foreach ($blocks as $size) {
            if ($size == 3) $threeHourNeeded++;
        }
    }

    // Hitung blok 3 jam yang sudah terpasang
    $threeHourPlaced = 0;
    foreach ($this->placedBlocks as $pb) {
        if (count($pb['slots']) == 3) {
            $threeHourPlaced++;
        }
    }

    $threeHourRemaining = max(0, $threeHourNeeded - $threeHourPlaced);

    // Hitung slot consecutive yang tersedia untuk blok 3 jam
    $consecutiveSlots = 0;
    foreach ($this->activeDays as $day) {
        $slotNumbers = array_keys($this->kbmSlots[$day] ?? []);
        $n = count($slotNumbers);
        for ($i = 0; $i <= $n - 3; $i++) {
            $window = array_slice($slotNumbers, $i, 3);
            $isFree = true;
            foreach ($window as $slot) {
                // Cek apakah slot ini sudah terisi (oleh guru atau kelas manapun)
                $isOccupied = false;
                foreach ($this->placedBlocks as $pb) {
                    if ($pb['day'] == $day && in_array($slot, $pb['slots'])) {
                        $isOccupied = true;
                        break;
                    }
                }
                if ($isOccupied) {
                    $isFree = false;
                    break;
                }
            }
            if ($isFree) $consecutiveSlots++;
        }
    }

    // Setiap blok 3 jam butuh 1 window consecutive
    $isReady = $consecutiveSlots >= $threeHourRemaining;
    $message = $isReady 
        ? "✅ Siap! Tersedia {$consecutiveSlots} slot consecutive, dibutuhkan {$threeHourRemaining} blok 3 jam."
        : "⚠️ Belum siap! Tersedia {$consecutiveSlots} slot consecutive, dibutuhkan {$threeHourRemaining} blok 3 jam. Lakukan repair dulu.";

    return [
        'needed' => $threeHourRemaining,
        'available' => $consecutiveSlots,
        'is_ready' => $isReady,
        'message' => $message,
    ];
}

protected function loadAllEntriesFromDatabase(int $scheduleId): void
{
    $entries = $this->entryModel->db->table('schedule_entries')
        ->where('schedule_id', $scheduleId)
        ->where('is_locked', 0)
        ->where('is_manual', 0)
        ->get()
        ->getResultArray();

    $grouped = [];
    foreach ($entries as $e) {
        $key = $e['schedule_load_id'] . '_' . $e['day'];
        if (!isset($grouped[$key])) {
            $grouped[$key] = [];
        }
        $grouped[$key][] = $e;
    }

    $nextBlockId = 50000;
    foreach ($grouped as $group) {
        if (empty($group)) continue;
        $first = $group[0];
        $slots = array_map(fn($r) => (int)$r['slot_number'], $group);
        sort($slots);
        $bid = $nextBlockId++;
        
        if (isset($this->placedBlocks[$bid])) continue;
        
        $existingBlock = [
            'block_id'   => $bid,
            'load_id'    => $first['schedule_load_id'],
            'teacher_id' => (int)$first['teacher_id'],
            'class_id'   => (int)$first['classroom_id'],
            'subject_id' => (int)$first['school_subject_id'],
            'size'       => count($slots),
            'difficulty' => 0,
        ];
        // Daftarkan ke allBlocks
        $this->allBlocks[]      = $existingBlock;
        $this->blockIndex[$bid] = $existingBlock;

        $this->placeBlock($existingBlock, (int)$first['day'], $slots);
    }
}

// ==================================================================
// RESET ENTRIES BERDASARKAN UKURAN BLOK
// ==================================================================

public function resetEntriesByBlockSize(int $scheduleId, int $blockSize): int
{
    // Ambil semua entry
    $entries = $this->entryModel->where('schedule_id', $scheduleId)
        ->where('is_locked', 0)
        ->where('is_manual', 0)
        ->findAll();
    
    $deleted = 0;
    foreach ($entries as $entry) {
        $load = $this->loadModel->find($entry['schedule_load_id']);
        if ($load) {
            $blocks = $this->parseSplitConfig($load['hours_per_week'], $load['split_config'] ?? '');
            if (in_array($blockSize, $blocks)) {
                $this->entryModel->delete($entry['id']);
                $deleted++;
            }
        }
    }
    
    return $deleted;
}

// ==================================================================
// REPAIR BLOK 2 UNTUK MEMBUKA SLOT 3 JAM
// ==================================================================

public function repairTwoHourBlocksForThreeHour(int $scheduleId, int $yearId, int $periodId, string $activeDays): array
{
    $this->reset();
    $this->globalMaxHours = $this->settingModel->getGlobalMaxHours();
    $this->activeDays = array_map('intval', explode(',', $activeDays));
    $this->loadKbmSlots();
    $this->loadAllSlotTypes();
    $this->loadBlockedMap();
    $this->preloadTeacherData();

    // Load existing entries
    $this->loadAllEntriesFromDatabase($scheduleId);
    $this->loadRetainedEntries($scheduleId, false);

    // Hitung kebutuhan blok 3 jam
    $loads = $this->loadModel->getForGenerator($yearId, $periodId);
    $threeHourNeeded = 0;
    foreach ($loads as $load) {
        $blocks = $this->parseSplitConfig($load['hours_per_week'], $load['split_config'] ?? '');
        foreach ($blocks as $size) {
            if ($size == 3) $threeHourNeeded++;
        }
    }

    // Hitung blok 3 jam yang sudah terpasang
    $threeHourPlaced = 0;
    foreach ($this->placedBlocks as $pb) {
        if (count($pb['slots']) == 3 && empty($pb['is_retained'])) {
            $threeHourPlaced++;
        }
    }
    $threeHourRemaining = max(0, $threeHourNeeded - $threeHourPlaced);

    // Hitung slot consecutive sebelum repair
    $consecutiveBefore = $this->countConsecutiveSlotsFor3();

    // Geser blok 2 jam yang menghalangi
    $blocksMoved = 0;
    $maxIterations = 100;
    
    for ($iter = 0; $iter < $maxIterations; $iter++) {
        $moved = false;
        
        foreach ($this->placedBlocks as $bid => $pb) {
            if (!empty($pb['is_retained'])) continue;
            if (count($pb['slots']) != 2) continue;
            
            // Cek apakah blok 2 jam ini menghalangi slot untuk 3 jam
            $isObstructing = $this->isBlockObstructingThreeHour($pb);
            
            if ($isObstructing) {
                $block = $this->findBlockById($bid);
                if ($block) {
                    $newPos = $this->findAnyWindowForBlock($block);
                    if ($newPos && ($newPos['day'] != $pb['day'] || $newPos['slots'] != $pb['slots'])) {
                        $this->removeBlock($bid);
                        $this->placeBlock($block, $newPos['day'], $newPos['slots']);
                        $blocksMoved++;
                        $moved = true;
                        break;
                    }
                }
            }
        }
        
        if (!$moved) break;
        
        // Hitung ulang apakah sudah cukup
        $consecutiveNow = $this->countConsecutiveSlotsFor3();
        if ($consecutiveNow >= $threeHourRemaining) break;
    }

    $consecutiveAfter = $this->countConsecutiveSlotsFor3();
    
    // Simpan perubahan
    $this->saveAllEntriesToDatabase($scheduleId);
    
    $isReady = $consecutiveAfter >= $threeHourRemaining;
    $message = $isReady 
        ? "✅ Siap! Slot consecutive: {$consecutiveAfter}, dibutuhkan: {$threeHourRemaining} blok 3 jam."
        : "⚠️ Belum siap! Slot consecutive: {$consecutiveAfter}, dibutuhkan: {$threeHourRemaining} blok 3 jam. Ulangi repair.";

    return [
        'blocks_moved' => $blocksMoved,
        'consecutive_before' => $consecutiveBefore,
        'consecutive_after' => $consecutiveAfter,
        'needed' => $threeHourRemaining,
        'is_ready' => $isReady,
        'message' => $message,
    ];
}

// ==================================================================
// GET PELUANG 3 JAM (INFO SAJA)
// ==================================================================

public function getThreeHourOpportunity(int $scheduleId, int $yearId, int $periodId, string $activeDays): array
{
    $this->reset();
    $this->globalMaxHours = $this->settingModel->getGlobalMaxHours();
    $this->activeDays = array_map('intval', explode(',', $activeDays));
    $this->loadKbmSlots();
    $this->loadAllSlotTypes();
    $this->loadBlockedMap();
    $this->preloadTeacherData();
    
    $this->loadAllEntriesFromDatabase($scheduleId);
    $this->loadRetainedEntries($scheduleId, false);
    
    // Hitung kebutuhan blok 3 jam
    $loads = $this->loadModel->getForGenerator($yearId, $periodId);
    $threeHourNeeded = 0;
    foreach ($loads as $load) {
        $blocks = $this->parseSplitConfig($load['hours_per_week'], $load['split_config'] ?? '');
        foreach ($blocks as $size) {
            if ($size == 3) $threeHourNeeded++;
        }
    }
    
    // Hitung blok 3 jam yang sudah terpasang
    $threeHourPlaced = 0;
    foreach ($this->placedBlocks as $pb) {
        if (count($pb['slots']) == 3 && empty($pb['is_retained'])) {
            $threeHourPlaced++;
        }
    }
    $threeHourRemaining = max(0, $threeHourNeeded - $threeHourPlaced);
    
    // Hitung slot consecutive yang tersedia
    $consecutiveAvailable = $this->countConsecutiveSlotsFor3();
    
    $isReady = $consecutiveAvailable >= $threeHourRemaining;
    $message = $isReady 
        ? "✅ Siap! Tersedia {$consecutiveAvailable} slot consecutive, dibutuhkan {$threeHourRemaining} blok 3 jam."
        : "⚠️ Belum siap! Tersedia {$consecutiveAvailable} slot consecutive, dibutuhkan {$threeHourRemaining} blok 3 jam. Lakukan repair.";

    return [
        'needed' => $threeHourRemaining,
        'available' => $consecutiveAvailable,
        'is_ready' => $isReady,
        'message' => $message,
    ];
}

/**
 * Hitung jumlah slot consecutive (ukuran 3) yang tersedia
 */
protected function countConsecutiveSlotsFor3(): int
{
    $count = 0;
    foreach ($this->activeDays as $day) {
        $slotNumbers = array_keys($this->kbmSlots[$day] ?? []);
        $n = count($slotNumbers);
        for ($i = 0; $i <= $n - 3; $i++) {
            $window = array_slice($slotNumbers, $i, 3);
            $isFree = true;
            foreach ($window as $slot) {
                foreach ($this->placedBlocks as $pb) {
                    if ($pb['day'] == $day && in_array($slot, $pb['slots'])) {
                        $isFree = false;
                        break 2;
                    }
                }
            }
            if ($isFree) $count++;
        }
    }
    return $count;
}

/**
 * Cek apakah blok 2 jam menghalangi slot untuk blok 3 jam
 */
protected function isBlockObstructingThreeHour(array $pb): bool
{
    $day = $pb['day'];
    $slots = $pb['slots'];
    $slotNumbers = array_keys($this->kbmSlots[$day] ?? []);
    $n = count($slotNumbers);
    
    for ($i = 0; $i <= $n - 3; $i++) {
        $window = array_slice($slotNumbers, $i, 3);
        $overlap = count(array_intersect($window, $slots));
        // Jika blok ini mengisi 1-2 slot di window yang hampir kosong
        if ($overlap > 0 && $overlap < 3) {
            // Cek apakah window lain sudah terisi block lain
            $otherOccupied = false;
            foreach ($window as $slot) {
                if (!in_array($slot, $slots)) {
                    foreach ($this->placedBlocks as $otherPb) {
                        if ($otherPb['block_id'] != $pb['block_id'] && 
                            $otherPb['day'] == $day && 
                            in_array($slot, $otherPb['slots'])) {
                            $otherOccupied = true;
                            break 2;
                        }
                    }
                }
            }
            if (!$otherOccupied) return true;
        }
    }
    return false;
}

/**
 * Cari window untuk block (fallback)
 */
protected function findAnyWindowForBlock(array $block): ?array
{
    $teacherId = $block['teacher_id'];
    $classId = $block['class_id'];
    $size = $block['size'];
    
    $days = $this->activeDays;
    shuffle($days);
    
    foreach ($days as $day) {
        $slotNumbers = array_keys($this->kbmSlots[$day] ?? []);
        $n = count($slotNumbers);
        if ($n < $size) continue;
        
        for ($i = 0; $i <= $n - $size; $i++) {
            $window = array_slice($slotNumbers, $i, $size);
            if ($this->isWindowFree($teacherId, $classId, $day, $window)) {
                return ['day' => $day, 'slots' => $window];
            }
        }
    }
    return null;
}

/**
 * Simpan semua entries ke database
 */
protected function saveAllEntriesToDatabase(int $scheduleId): void
{
    // Hapus semua generated entries
    $this->entryModel->where('schedule_id', $scheduleId)
        ->where('is_locked', 0)
        ->where('is_manual', 0)
        ->delete();
    
    // Insert ulang
    $batchInsert = [];
    foreach ($this->placedBlocks as $pb) {
        if (!empty($pb['is_retained'])) continue;
        foreach ($pb['slots'] as $slot) {
            $batchInsert[] = [
                'schedule_id'       => $scheduleId,
                'schedule_load_id'  => $pb['load_id'],
                'teacher_id'        => $pb['teacher_id'],
                'classroom_id'      => $pb['class_id'],
                'school_subject_id' => $pb['subject_id'],
                'day'               => $pb['day'],
                'slot_number'       => $slot,
                'timeslot_id'       => $this->kbmSlots[$pb['day']][$slot]['id'],
                'is_manual'         => 0,
                'is_locked'         => 0,
                'created_at'        => date('Y-m-d H:i:s'),
                'updated_at'        => date('Y-m-d H:i:s'),
            ];
        }
    }
    
    if (!empty($batchInsert)) {
        $this->entryModel->db->table('schedule_entries')->insertBatch($batchInsert);
    }
}

// ══════════════════════════════════════════════════════════════════
// METHOD STUB — dipanggil controller untuk optimasi manual
// ══════════════════════════════════════════════════════════════════

/**
 * Cari slot kosong untuk blok yang belum terpasang.
 * Jalankan generate ulang untuk beban yang belum terpasang saja
 * (tanpa mereset yang sudah ada).
 */
public function findOpportunity(
    int    $scheduleId,
    int    $yearId,
    int    $periodId,
    string $activeDays = '1,2,3,4,5'
): array {
    // Generate ulang dengan keepManual=true
    // Hanya blok yang belum terpasang yang akan dicoba ulang
    $result = $this->generate(
        scheduleId: $scheduleId,
        yearId:     $yearId,
        periodId:   $periodId,
        activeDays: $activeDays,
        keepManual: true
    );

    $conflictsBefore = count($result['conflicts'] ?? []);

    return [
        'slots_found'       => $result['placed'] ?? 0,
        'conflicts_before'  => $conflictsBefore,
        'conflicts_after'   => $conflictsBefore,
    ];
}

/**
 * Masukkan blok yang gagal ke slot kosong yang tersisa.
 * Sama seperti findOpportunity tapi dengan relaxasi HC7.
 */
public function insertFailedBlocks(
    int    $scheduleId,
    int    $yearId,
    int    $periodId,
    string $activeDays = '1,2,3,4,5'
): array {
    $result = $this->generate(
        scheduleId: $scheduleId,
        yearId:     $yearId,
        periodId:   $periodId,
        activeDays: $activeDays,
        keepManual: true
    );

    return [
        'inserted'             => $result['placed'] ?? 0,
        'remaining_conflicts'  => count($result['conflicts'] ?? []),
    ];
}

/**
 * Optimasi lanjutan — jalankan swap phase lebih agresif
 * untuk memindahkan blok yang menghalangi blok gagal.
 */
public function findOpportunityAdvanced(
    int    $scheduleId,
    int    $yearId,
    int    $periodId,
    string $activeDays = '1,2,3,4,5'
): array {
    $conflictsBefore = $this->entryModel->db
        ->table('schedule_loads sl')
        ->select('COUNT(*) as total')
        ->join('schedule_entries se', 'se.schedule_load_id = sl.id AND se.schedule_id = ' . $scheduleId, 'left')
        ->where('sl.academic_year_id', $yearId)
        ->where('sl.academic_period_id', $periodId)
        ->get()->getRowArray();
    $conflictsBefore = (int)($conflictsBefore['total'] ?? 0);

    $result = $this->generate(
        scheduleId: $scheduleId,
        yearId:     $yearId,
        periodId:   $periodId,
        activeDays: $activeDays,
        keepManual: true
    );

    $conflictsAfter = count($result['conflicts'] ?? []);

    return [
        'slots_found'       => $result['placed'] ?? 0,
        'blocks_moved'      => max(0, $conflictsBefore - $conflictsAfter),
        'conflicts_before'  => $conflictsBefore,
        'conflicts_after'   => $conflictsAfter,
    ];
}

/**
 * Geser blok 1 jam yang menghalangi blok lebih besar.
 * Gunakan generate ulang dengan keepManual=true sebagai pendekatan.
 */
public function findAndShiftOneHourBlocks(
    int    $scheduleId,
    int    $yearId,
    int    $periodId,
    string $activeDays = '1,2,3,4,5'
): array {
    $result = $this->generate(
        scheduleId: $scheduleId,
        yearId:     $yearId,
        periodId:   $periodId,
        activeDays: $activeDays,
        keepManual: true
    );

    return [
        'blocks_shifted'       => 0,
        'opportunities_found'  => $result['placed'] ?? 0,
        'conflicts_before'     => count($result['conflicts'] ?? []),
        'conflicts_after'      => count($result['conflicts'] ?? []),
    ];
}

    protected function packClassrooms(array &$failedPackBlocks = []): bool
    {
        // Kelompokkan blok berdasarkan classroom
        $classBlocks = [];
        foreach ($this->allBlocks as $block) {
            if ($block['block_id'] < 0) continue; // skip retained
            $classBlocks[$block['class_id']][] = $block;
        }

        $allPlaced = [];

        foreach ($classBlocks as $classId => $blocks) {
            // Urutkan blok dari yang terbesar
            usort($blocks, fn($a, $b) => $b['size'] <=> $a['size']);

            // Cari slot yang sudah terisi oleh retained entries
            $retainedSlots = [];
            foreach ($this->placedBlocks as $pb) {
                if ($pb['class_id'] == $classId && !empty($pb['is_retained'])) {
                    foreach ($pb['slots'] as $s) {
                        $retainedSlots[$pb['day']][$s] = true;
                    }
                }
            }

            // Bangun segmen kontigu yang tersedia di setiap hari
            $daySegments = [];
            foreach ($this->activeDays as $day) {
                $slots = array_keys($this->kbmSlots[$day] ?? []);
                sort($slots);

                $segments = [];
                $currentSeg = [];
                foreach ($slots as $slot) {
                    if (isset($retainedSlots[$day][$slot])) {
                        if (!empty($currentSeg)) {
                            $segments[] = $currentSeg;
                            $currentSeg = [];
                        }
                    } else {
                        $currentSeg[] = $slot;
                    }
                }
                if (!empty($currentSeg)) {
                    $segments[] = $currentSeg;
                }
                $daySegments[$day] = $segments;
            }

            // Jalankan pengemasan backtracking (gapless)
            $placed = [];
            $this->backtrackStates = 0;
            $searchDays = $this->activeDays;
            shuffle($searchDays);
            $success = $this->packClassroomBacktracking($blocks, $daySegments, 0, $placed, $searchDays, 0, 0);
            if (!$success) {
                $this->logs[] = "  [DEBUG] Backtracking gagal untuk Classroom ID {$classId}. Menggunakan greedy packing dengan gap...";
                $placed = [];
                $tempSegments = $daySegments;
                $greedySuccess = true;

                foreach ($blocks as $block) {
                    $size = $block['size'];
                    $placedBlock = false;

                    foreach ($tempSegments as $day => $segments) {
                        foreach ($segments as $segIdx => $segment) {
                            $n = count($segment);
                            if ($n >= $size) {
                                // Ambil window ukuran $size dari awal segment
                                $window = array_slice($segment, 0, $size);
                                $placed[$block['block_id']] = ['day' => $day, 'slots' => $window];

                                // Update segment (buang slot terpakai)
                                $tempSegments[$day][$segIdx] = array_slice($segment, $size);
                                if (empty($tempSegments[$day][$segIdx])) {
                                    unset($tempSegments[$day][$segIdx]);
                                }
                                $placedBlock = true;
                                break 2;
                            }
                        }
                    }

                    if (!$placedBlock) {
                        $greedySuccess = false;
                        break;
                    }
                }

                if (!$greedySuccess) {
                    $this->logs[] = "  [ERROR] Greedy packing juga gagal untuk Classroom ID {$classId}!";
                    foreach ($blocks as $b) {
                        $failedPackBlocks[] = $b;
                    }
                    continue;
                }
            }

            foreach ($placed as $bid => $pos) {
                $allPlaced[$bid] = $pos;
            }
        }

        // Reset dynamic state & place everything
        $retainedPlaced = [];
        foreach ($this->placedBlocks as $bid => $pb) {
            if (!empty($pb['is_retained'])) {
                $retainedPlaced[$bid] = $pb;
            }
        }

        // Reset state
        $this->placedBlocks = [];
        $this->placedBlocksByClass = [];
        $this->placedBlocksBySize = [];
        $this->teacherSlot = [];
        $this->classSlot = [];
        $this->dayCount = [];
        $this->loadDayMap = [];
        $this->classSubjectDay2 = [];
        $this->classSubjectLoadCount = [];
        $this->loadLessonDistributionPenalty = [];
        $this->availabilityConflictsCount = 0;
        $this->teacherClashesCount = 0;
        $this->classClashesCount = 0;
        $this->hc6OverHours = 0;
        $this->hc7ConflictsCount = 0;
        $this->hc8ConflictsCount = 0;
        $this->lessonDistributionPenalty = 0;

        // Tempatkan retained blocks kembali
        foreach ($retainedPlaced as $bid => $pb) {
            $block = $this->findBlockById($bid);
            if ($block) {
                $this->placeBlock($block, $pb['day'], $pb['slots']);
            }
        }

        // Tempatkan packed blocks
        foreach ($allPlaced as $bid => $pos) {
            $block = $this->findBlockById($bid);
            if ($block) {
                $this->placeBlock($block, $pos['day'], $pos['slots']);
            }
        }

        return empty($failedPackBlocks);
    }

    protected function packClassroomBacktracking(
        array $unplacedBlocks,
        array $daySegments,
        int $blockIdx,
        array &$placed,
        array $searchDays,
        int $lastDayIdx = 0,
        int $lastSegIdx = 0
    ): bool {
        $this->backtrackStates++;
        if ($this->backtrackStates > 100000) {
            return false;
        }

        if ($blockIdx >= count($unplacedBlocks)) {
            return true;
        }

        $block = $unplacedBlocks[$blockIdx];
        $size = $block['size'];

        // Symmetry breaking check
        $isSameSize = false;
        if ($blockIdx > 0 && $unplacedBlocks[$blockIdx - 1]['size'] === $size) {
            $isSameSize = true;
        }

        // Pruning: Check if the largest remaining block can fit in the largest available segment
        $maxSegSize = 0;
        foreach ($daySegments as $day => $segments) {
            foreach ($segments as $segment) {
                $segSize = count($segment);
                if ($segSize > $maxSegSize) {
                    $maxSegSize = $segSize;
                }
            }
        }
        if ($size > $maxSegSize) {
            return false;
        }

        foreach ($searchDays as $dayIdx => $day) {
            if ($isSameSize && $dayIdx < $lastDayIdx) {
                continue;
            }

            $segments = $daySegments[$day] ?? [];
            foreach ($segments as $segIdx => $segment) {
                if ($isSameSize && $dayIdx === $lastDayIdx && $segIdx < $lastSegIdx) {
                    continue;
                }

                $n = count($segment);
                if ($n >= $size) {
                    // Letakkan di awal segmen secara rapat (gapless start = 0)
                    $window = array_slice($segment, 0, $size);

                    $placed[$block['block_id']] = ['day' => $day, 'slots' => $window];

                    // Konstruksi segmen baru
                    $newSegments = $segments;
                    $newSegments[$segIdx] = array_slice($segment, $size);
                    if (empty($newSegments[$segIdx])) {
                        unset($newSegments[$segIdx]);
                    }

                    $newDaySegments = $daySegments;
                    $newDaySegments[$day] = array_values($newSegments);

                    if ($this->packClassroomBacktracking($unplacedBlocks, $newDaySegments, $blockIdx + 1, $placed, $searchDays, $dayIdx, $segIdx)) {
                        return true;
                    }

                    unset($placed[$block['block_id']]);
                }
            }
        }

        return false;
    }

}
