<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class FixSchoolYearColumns extends Migration
{
    /**
     * Tables that directly own an academic year.
     *
     * school_year stays as text by design. Valid values use YYYY-YYYY and
     * the second year must be exactly the first year + 1.
     */
    private array $yearOwnerTables = [
        'additional_charges',
        'archived_paypal_transactions',
        'attendance_data',
        'attendance_day',
        'attendance_record',
        'attendance_tracking',
        'badge_print_logs',
        'below_sixty_decisions',
        'calendar_events',
        'certificate_records',
        'classSection',
        'class_progress_reports',
        'competitions',
        'current_flag',
        'discount_vouchers',
        'early_dismissal_signatures',
        'enrollments',
        'events',
        'exams',
        'exam_drafts',
        'expenses',
        'final_exam',
        'final_score',
        'flag',
        'grading_locks',
        'homework',
        'inventory_movements',
        'invoices',
        'late_slip_logs',
        'manual_payments',
        'midterm_exam',
        'missing_score_overrides',
        'parent_attendance_reports',
        'parent_meeting_schedules',
        'parent_notifications',
        'participation',
        'payments',
        'placement_batches',
        'print_requests',
        'project',
        'quiz',
        'refunds',
        'reimbursements',
        'reimbursement_batches',
        'report_card_acknowledgements',
        'scan_log',
        'score_comments',
        'semester_scores',
        'staff_attendance',
        'student_class',
        'student_decisions',
        'teacher_attendance_data',
        'teacher_class',
        'teacher_submission_notification_history',
        'whatsapp_group_links',
        'whatsapp_group_memberships',
    ];

    /** Tables where school_year is redundant or conceptually incorrect. */
    private array $tablesWithoutDirectYear = [
        'chapters',
        'classes',
        'class_preparation_log',
        'class_prep_adjustments',
        'contactus',
        'emergency_contacts',
        'inventory_items',
        'invoice_students_list',
        'ip_attempts',
        'login_activity',
        'messages',
        'notifications',
        'notification_recipients',
        'parents',
        'paypal_transactions',
        'placement_levels',
        'preferences',
        'staff',
        'students',
        'support_requests',
        'users',
        'user_notifications',
    ];

    /** Tables where semester is redundant or conceptually incorrect. */
    private array $tablesWithoutDirectSemester = [
        'badge_print_logs',
        'classes',
        'contactus',
        'emergency_contacts',
        'inventory_items',
        'invoice_students_list',
        'ip_attempts',
        'notification_recipients',
        'notifications',
        'parents',
        'support_requests',
        'user_notifications',
        'whatsapp_group_links',
    ];

    public function up(): void
    {
        if ($this->db->DBDriver !== 'MySQLi') {
            throw new \RuntimeException(
                'This migration targets MySQL 8 because it uses enforced CHECK constraints.'
            );
        }

        $this->db->resetDataCache();

        // These annual entities lacked a school_year column in the audited schema.
        foreach (['class_progress_reports', 'exams', 'print_requests'] as $table) {
            if ($this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table)) {
                // Nullable avoids inventing a year for existing rows.
                // Backfill it, then make it NOT NULL in a later migration.
                $this->forge->addColumn($table, [
                    'school_year' => [
                        'type'       => 'VARCHAR',
                        'constraint' => 9,
                        'null'       => true,
                    ],
                ]);
            }
        }

        $this->db->resetDataCache();

        // Validate and normalize every direct owner to VARCHAR(9).
        foreach ($this->yearOwnerTables as $table) {
            if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
                continue;
            }

            $this->assertValidSchoolYears($table, 'school_year');
            $this->normalizeYearColumn($table, 'school_year');
            $this->ensureYearCheckConstraint($table, 'school_year');
            $this->ensureYearIndex($table, 'school_year');
        }

        // Promotion is a transition and legitimately owns both source and target years.
        if ($this->db->tableExists('promotion_queue')) {
            foreach (['school_year_from', 'school_year_to'] as $column) {
                if (! $this->db->fieldExists($column, 'promotion_queue')) {
                    continue;
                }

                $this->assertValidSchoolYears('promotion_queue', $column);
                $this->normalizeYearColumn('promotion_queue', $column);
                $this->ensureYearCheckConstraint('promotion_queue', $column);
                $this->ensureYearIndex('promotion_queue', $column);
            }
        }

        $this->db->resetDataCache();

        // Do not drop redundant school_year/semester columns here. Restored
        // production dumps may still contain meaningful historical data in
        // those columns, and running migrate must not destroy it.

        $this->db->resetDataCache();

        foreach ($this->yearOwnerTables as $table) {
            if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
                continue;
            }

            $this->ensureYearIndex($table, 'school_year');
        }
    }

    public function down(): void
    {
        throw new \RuntimeException(
            'This migration is intentionally irreversible because dropping school_year columns destroys data. Restore from backup instead of pretending rollback can resurrect it.'
        );
    }

    private function assertValidSchoolYears(string $table, string $column): void
    {
        $tableName = $this->quoteIdentifier($table);
        $columnName = $this->quoteIdentifier($column);

        $invalid = $this->db->query(
            "SELECT COUNT(*) AS aggregate
             FROM {$tableName}
             WHERE {$columnName} IS NOT NULL
               AND (
                    {$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$'
                    OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED)
                       <> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1
               )"
        )->getRow();

        if ((int) ($invalid->aggregate ?? 0) > 0) {
            throw new \RuntimeException(
                "Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s). Expected YYYY-YYYY with consecutive years, for example 2025-2026."
            );
        }
    }

    private function normalizeYearColumn(string $table, string $column): void
    {
        $metadata = $this->db->query(
            'SELECT IS_NULLABLE, COLLATION_NAME
             FROM information_schema.COLUMNS
             WHERE TABLE_SCHEMA = DATABASE()
               AND TABLE_NAME = ?
               AND COLUMN_NAME = ?',
            [$table, $column]
        )->getRow();

        if ($metadata === null) {
            return;
        }

        $nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL';
        $collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME)
            ? ' COLLATE ' . $metadata->COLLATION_NAME
            : '';

        $this->db->query(sprintf(
            'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s',
            $this->quoteIdentifier($table),
            $this->quoteIdentifier($column),
            $collation,
            $nullable
        ));
    }

    private function ensureYearCheckConstraint(string $table, string $column): void
    {
        $constraint = $this->objectName('chk_sy', $table, $column);

        $exists = $this->db->query(
            'SELECT COUNT(*) AS aggregate
             FROM information_schema.TABLE_CONSTRAINTS
             WHERE CONSTRAINT_SCHEMA = DATABASE()
               AND TABLE_NAME = ?
               AND CONSTRAINT_NAME = ?
               AND CONSTRAINT_TYPE = \'CHECK\'',
            [$table, $constraint]
        )->getRow();

        if ((int) ($exists->aggregate ?? 0) > 0) {
            return;
        }

        $columnName = $this->quoteIdentifier($column);

        $this->db->query(sprintf(
            "ALTER TABLE %s ADD CONSTRAINT %s CHECK (
                %s IS NULL OR (
                    %s REGEXP '^[0-9]{4}-[0-9]{4}$'
                    AND CAST(RIGHT(%s, 4) AS UNSIGNED)
                        = CAST(LEFT(%s, 4) AS UNSIGNED) + 1
                )
            )",
            $this->quoteIdentifier($table),
            $this->quoteIdentifier($constraint),
            $columnName,
            $columnName,
            $columnName,
            $columnName
        ));
    }

    private function ensureYearIndex(string $table, string $column): void
    {
        $indexed = $this->db->query(
            'SELECT COUNT(*) AS aggregate
             FROM information_schema.STATISTICS
             WHERE TABLE_SCHEMA = DATABASE()
               AND TABLE_NAME = ?
               AND COLUMN_NAME = ?',
            [$table, $column]
        )->getRow();

        if ((int) ($indexed->aggregate ?? 0) > 0) {
            return;
        }

        $index = $this->objectName('idx_sy', $table, $column);

        $this->db->query(sprintf(
            'ALTER TABLE %s ADD INDEX %s (%s)',
            $this->quoteIdentifier($table),
            $this->quoteIdentifier($index),
            $this->quoteIdentifier($column)
        ));
    }

    private function dropIndexesContainingColumn(string $table, string $column): void
    {
        $indexes = $this->db->query(
            'SELECT DISTINCT INDEX_NAME
             FROM information_schema.STATISTICS
             WHERE TABLE_SCHEMA = DATABASE()
               AND TABLE_NAME = ?
               AND COLUMN_NAME = ?
               AND INDEX_NAME <> \'PRIMARY\'',
            [$table, $column]
        )->getResult();

        foreach ($indexes as $index) {
            $this->db->query(sprintf(
                'ALTER TABLE %s DROP INDEX %s',
                $this->quoteIdentifier($table),
                $this->quoteIdentifier($index->INDEX_NAME)
            ));
        }
    }

    private function dropChecksContainingColumn(string $table, string $column): void
    {
        $checks = $this->db->query(
            'SELECT tc.CONSTRAINT_NAME
             FROM information_schema.TABLE_CONSTRAINTS tc
             INNER JOIN information_schema.CHECK_CONSTRAINTS cc
                ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
               AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
             WHERE tc.CONSTRAINT_SCHEMA = DATABASE()
               AND tc.TABLE_NAME = ?
               AND tc.CONSTRAINT_TYPE = \'CHECK\'
               AND cc.CHECK_CLAUSE LIKE ?',
            [$table, '%' . $column . '%']
        )->getResult();

        foreach ($checks as $check) {
            $this->db->query(sprintf(
                'ALTER TABLE %s DROP CHECK %s',
                $this->quoteIdentifier($table),
                $this->quoteIdentifier($check->CONSTRAINT_NAME)
            ));
        }
    }

    private function objectName(string $prefix, string $table, string $column): string
    {
        // MySQL identifiers are limited to 64 characters.
        return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64);
    }

    private function quoteIdentifier(string $identifier): string
    {
        return '`' . str_replace('`', '``', $identifier) . '`';
    }
}
