<?php

declare(strict_types=1);
ob_start();

$root = dirname(__DIR__);
$tmp = sys_get_temp_dir() . '/globinours-tests-' . bin2hex(random_bytes(5)) . '.sqlite';
putenv('GLOBINOURS_DB_PATH=' . $tmp);
putenv('GLOBINOURS_BACKUP_DATA_ONLY=1');
foreach (
    [
        'DB',
        'Migrations',
        'I18n',
        'AppSettings',
        'SecurityService',
        'Auth',
        'TrustedDeviceService',
        'PermissionService',
        'AuditService',
        'BackupService',
        'InstallationService',
        'InventoryService',
        'AgendaService',
        'CalendarFeedService',
        'NotificationService',
        'PrivacyService',
        'StatisticsService',
        'ShelterRoomService',
        'GlobalSearchService',
        'PrivateMediaService',
        'DemoDataService',
    ]
    as $class
) {
    require_once $root . '/app/Services/' . $class . '.php';
}
$passed = 0;
$failed = 0;
$createdBackup = null;
$test = static function (string $name, callable $callback) use (&$passed, &$failed): void {
    try {
        $callback();
        echo "✅ $name\n";
        $passed++;
    } catch (Throwable $e) {
        echo "❌ $name — {$e->getMessage()}\n";
        $failed++;
    }
};
$assert = static function (bool $condition, string $message = 'Assertion échouée'): void {
    if (!$condition) {
        throw new RuntimeException($message);
    }
};
try {
    Migrations::run($root . '/migrations');
    I18n::setLocale('fr');
    $db = DB::pdo();
    Auth::start();
    $test('Toutes les migrations sont appliquées', function () use ($db, $root, $assert): void {
        $expected = count(glob($root . '/migrations/*.sql') ?: []);
        $actual = (int) $db->query('SELECT COUNT(*) FROM _migrations')->fetchColumn();
        $assert($actual === $expected, "$actual/$expected migrations");
    });
    $test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void {
        $routes = $db
            ->query('SELECT accro FROM ref_med_administration ORDER BY id')
            ->fetchAll(PDO::FETCH_COLUMN);
        $assert(count($routes) === 14, 'Référentiel des voies incomplet');
        $assert(in_array('PO', $routes, true) && in_array('SC', $routes, true) && in_array('IV', $routes, true));
        $historyColumns = array_column(
            $db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
            'name',
        );
        foreach (['type', 'label', 'details'] as $column) {
            $assert(in_array($column, $historyColumns, true), 'Colonne historique absente : ' . $column);
        }
        $assert(
            (int) $db->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='icad_cache'")->fetchColumn() === 1,
            'Cache i-CAD absent',
        );
    });
    $test('Une installation vierge initialise les médias privés', function () use ($db, $assert): void {
        $columns = array_column($db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC), 'name');
        $assert(in_array('is_public', $columns, true), 'Colonne is_public absente');
        $assert(
            (int) $db
                ->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='medical_photos'")
                ->fetchColumn() === 1,
            'Table medical_photos absente',
        );
        PrivateMediaService::migrateLegacy();
    });
    $test('La base SQLite temporaire est intègre', function () use ($db, $assert): void {
        $assert($db->query('PRAGMA integrity_check')->fetchColumn() === 'ok');
    });
    $test('La configuration initiale des salles est complète', function () use ($assert): void {
        $rooms = ShelterRoomService::byCode(false);
        foreach (['silver', 'twix', 'care', 'quarantine'] as $code) {
            $assert(isset($rooms[$code]), 'Salle absente : ' . $code);
        }
        $assert($rooms['silver']['name'] === 'Salle collective 1');
        $assert($rooms['twix']['name'] === 'Salle collective 2');
        $assert(
            count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5,
            'Les cinq box initiaux ne sont pas disponibles',
        );
    });
    $test('Les paramètres sont enregistrés et relus', function () use ($assert): void {
        AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null);
        $assert(AppSettings::get('association_name') === 'Refuge Test');
        $assert(AppSettings::get('app_language') === 'en');
        I18n::setLocale('fr');
    });
    $test('Les quatre bilans biologiques existent pour chats et chiens', function () use ($db, $assert): void {
        $rows = $db
            ->query(
                'SELECT analyzer_code,species_code,COUNT(*) total FROM lab_reference_ranges GROUP BY analyzer_code,species_code',
            )
            ->fetchAll(PDO::FETCH_ASSOC);
        $sets = [];
        foreach ($rows as $row) {
            $sets[$row['analyzer_code']][$row['species_code']] = (int) $row['total'];
        }
        foreach (['vetscan-hm5', 'renal', 'hepatic', 'thyroid'] as $code) {
            foreach (['chat', 'chien'] as $species) {
                $assert(($sets[$code][$species] ?? 0) > 0, "Référentiel $code/$species absent");
            }
        }
        $assert(
            (string) $db
                ->query("SELECT analyzer_name FROM lab_reference_ranges WHERE analyzer_code='vetscan-hm5' LIMIT 1")
                ->fetchColumn() === 'Bilan complet',
        );
    });
    $test('Authentification et mot de passe haché', function () use ($db, $assert): void {
        $assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
        $hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);
        $db->prepare(
            "INSERT INTO users(username,password_hash,display_name,role,active) VALUES('admin-test',?,'Administrateur test','admin',1)",
        )->execute([$hash]);
        $assert(!Auth::login('admin-test', 'incorrect'));
        $assert(
            (int) $db->query('SELECT COUNT(*) FROM login_attempts WHERE succeeded=0')->fetchColumn() === 1,
            'Échec de connexion non enregistré',
        );
        $assert(Auth::login('admin-test', 'MotDePasseSolide!'));
        $assert(Auth::is('admin'));
    });
    $test('Les permissions administrateur restent complètes', function () use ($assert): void {
        $assert(PermissionService::can('animals', 'edit'));
        $assert(PermissionService::can('medical', 'view'));
    });
    $test('Les permissions et répétitions de l’agenda sont opérationnelles', function () use ($db, $assert): void {
        $assert(PermissionService::can('agenda', 'edit'));
        $id = AgendaService::save([
            'item_type' => 'task',
            'title' => 'Tâche répétée de test',
            'date' => date('Y-m-01'),
            'time' => '09:00',
            'priority' => 'high',
            'repeat_rule' => 'weekly',
            'repeat_until' => date('Y-m-t'),
        ]);
        $month = AgendaService::month(new DateTimeImmutable('first day of this month'));
        $occurrences = array_values(array_filter($month, static fn($row) => (int) $row['id'] === $id));
        $assert(count($occurrences) >= 4, 'La répétition hebdomadaire n’est pas développée');
        AgendaService::setStatus($id, 'completed');
        $assert((string) $db->query('SELECT status FROM agenda_items WHERE id=' . $id)->fetchColumn() === 'completed');
    });
    $test('La vue hebdomadaire respecte le lundi, le dimanche et la préférence utilisateur', function () use (
        $db,
        $assert,
    ): void {
        $inside = AgendaService::save([
            'item_type' => 'task',
            'title' => 'Dans la semaine',
            'date' => date('Y-m-d'),
            'time' => '10:00',
        ]);
        $outside = AgendaService::save([
            'item_type' => 'task',
            'title' => 'Semaine suivante',
            'date' => (new DateTimeImmutable('monday next week'))->format('Y-m-d'),
            'time' => '10:00',
        ]);
        $ids = array_map('intval', array_column(AgendaService::week(new DateTimeImmutable('today')), 'id'));
        $assert(in_array($inside, $ids, true), 'Le jour courant est absent de sa semaine');
        $assert(!in_array($outside, $ids, true), 'La semaine suivante déborde dans la vue');
        $columns = array_column($db->query('PRAGMA table_info(users)')->fetchAll(PDO::FETCH_ASSOC), 'name');
        $assert(in_array('agenda_view', $columns, true), 'La préférence de vue agenda est absente');
    });
    $test('Les bénévoles de l’annuaire peuvent être mobilisés sur une mission', function () use ($db, $assert): void {
        $db->exec("INSERT INTO directory_contacts(kind,name) VALUES('person','Bénévole Agenda')");
        $volunteer = (int) $db->lastInsertId();
        $db->prepare("INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,'benevole')")->execute([
            $volunteer,
        ]);
        $event = AgendaService::save([
            'item_type' => 'transport',
            'title' => 'Mission bénévole',
            'date' => date('Y-m-d'),
            'time' => '14:00',
            'location' => 'Clinique',
            'volunteer_contact_ids' => [$volunteer],
        ]);
        $rows = array_values(
            array_filter(
                AgendaService::week(new DateTimeImmutable('today')),
                static fn($row) => (int) $row['id'] === $event,
            ),
        );
        $assert(
            count($rows) === 1 && str_contains((string) $rows[0]['volunteer_names'], 'Bénévole Agenda'),
            'Bénévole absent de la mission',
        );
        $assert(str_contains((string) $rows[0]['title'], 'Bénévole Agenda'), 'Affectation non visible dans l’agenda');
        $overlap = AgendaService::save([
            'item_type' => 'task',
            'title' => 'Mission simultanée',
            'date' => date('Y-m-d'),
            'time' => '14:30',
            'volunteer_contact_ids' => [$volunteer],
        ]);
        $assert(count(AgendaService::conflicts($overlap)) === 1, 'Le chevauchement du bénévole n’est pas détecté');
    });
    $test('L’export iCalendar et son abonnement privé sont révocables', function () use ($db, $assert): void {
        $token = CalendarFeedService::generateToken((int) Auth::id());
        $assert(
            strlen($token) === 64 && CalendarFeedService::userForToken($token) !== null,
            'Jeton iCalendar invalide',
        );
        $stored = (string) $db
            ->query('SELECT agenda_feed_token_hash FROM users WHERE id=' . (int) Auth::id())
            ->fetchColumn();
        $assert($stored === hash('sha256', $token) && $stored !== $token, 'Le jeton privé est stocké en clair');
        $ics = CalendarFeedService::render(AgendaService::week(new DateTimeImmutable('today')), 'Agenda Test');
        $assert(
            str_starts_with($ics, "BEGIN:VCALENDAR\r\n") &&
                str_contains($ics, 'BEGIN:VEVENT') &&
                str_contains($ics, 'END:VCALENDAR'),
            'Export iCalendar incomplet',
        );
        CalendarFeedService::revoke((int) Auth::id());
        $assert(CalendarFeedService::userForToken($token) === null, 'Le lien révoqué reste utilisable');
    });
    $test('Une échéance vaccinale produit une notification', function () use ($db, $assert): void {
        $db->exec("INSERT INTO ref_vaccines(name) VALUES('Test vaccin')");
        $v = (int) $db->lastInsertId();
        $db->exec(
            "INSERT INTO animals(internal_code,name,species,status) VALUES('TEST-001','Animal Test','chat','refuge')",
        );
        $a = (int) $db->lastInsertId();
        $db->prepare(
            "INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date) VALUES(?,?,date('now','-1 year'),date('now','-1 day'))",
        )->execute([$a, $v]);
        $alerts = NotificationService::alerts();
        $assert((bool) array_filter($alerts, static fn($x) => $x['key'] === 'vaccines'));
    });
    $test('Les rappels vaccinaux apparaissent automatiquement dans l’agenda', function () use ($assert): void {
        $month = AgendaService::month(new DateTimeImmutable('first day of this month'));
        $reminders = array_values(
            array_filter(
                $month,
                static fn($row) => ($row['source_type'] ?? '') === 'vaccine' && ($row['item_type'] ?? '') === 'vaccine',
            ),
        );
        $assert(count($reminders) >= 1, 'Aucun rappel vaccinal virtuel dans le calendrier');
        $assert((int) $reminders[0]['id'] < 0, 'Le rappel vaccinal ne doit pas devenir un événement éditable');
    });
    $test('Vermifuges et traitements alimentent l’agenda sans double saisie', function () use ($db, $assert): void {
        $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
        $dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn();
        $db->prepare(
            "INSERT INTO dewormings(animal_id,dewormer_id,administered_on,next_due_date) VALUES(?,?,date('now','-3 months'),date('now'))",
        )->execute([$animal, $dewormer]);
        $db->exec("INSERT INTO ref_medications(name) VALUES('Traitement agenda')");
        $medication = (int) $db->lastInsertId();
        $db->prepare(
            "INSERT INTO treatments(animal_id,medication_id,dose_text,start_date,end_date,ongoing) VALUES(?,?,'Dose test',date('now'),date('now','+1 day'),1)",
        )->execute([$animal, $medication]);
        $sources = array_column(AgendaService::upcoming(2), 'source_type');
        foreach (['deworming', 'treatment_start', 'treatment_end'] as $source) {
            $assert(in_array($source, $sources, true), 'Échéance absente : ' . $source);
        }
    });
    $test('Le parcours médical et adoption conserve ses relations', function () use ($db, $assert): void {
        $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
        $db->prepare(
            "INSERT INTO medical_notes(animal_id,kind,reason,created_by) VALUES(?,'controle','Contrôle de test',?)",
        )->execute([$animal, Auth::id()]);
        $db->prepare(
            "INSERT INTO adoptions(animal_id,adopter_name,adoption_date) VALUES(?,'Adoptant test',date('now'))",
        )->execute([$animal]);
        $assert((int) $db->query('SELECT COUNT(*) FROM medical_notes')->fetchColumn() === 1);
        $assert((int) $db->query('SELECT COUNT(*) FROM adoptions')->fetchColumn() === 1);
        $assert(!$db->query('PRAGMA foreign_key_check')->fetchAll());
    });
    $test('La fiche annuaire agrège adoptions et dépenses', function () use ($db, $assert): void {
        $db->exec("INSERT INTO directory_contacts(kind,name) VALUES('organization','Structure test')");
        $contact = (int) $db->lastInsertId();
        $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
        $db->prepare('UPDATE adoptions SET adopter_contact_id=? WHERE animal_id=?')->execute([$contact, $animal]);
        $db->prepare(
            "INSERT INTO animal_expenses(animal_id,clinic_contact_id,label,occurred_on,quantity,catalog_unit_cents,total_cents) VALUES(?,?,'Acte test',date('now'),1,1234,1234)",
        )->execute([$animal, $contact]);
        $q = $db->prepare(
            "SELECT (SELECT COUNT(*) FROM (SELECT animal_id FROM adoptions WHERE adopter_contact_id=dc.id UNION SELECT animal_id FROM animal_placements WHERE contact_id=dc.id AND event_type='adoption')) adopted,(SELECT COALESCE(SUM(total_cents),0) FROM animal_expenses WHERE clinic_contact_id=dc.id) total FROM directory_contacts dc WHERE dc.id=?",
        );
        $q->execute([$contact]);
        $row = $q->fetch();
        $assert((int) $row['adopted'] === 1);
        $assert((int) $row['total'] === 1234);
        $costs = StatisticsService::costs((int) date('Y'));
        $structure =
            array_values(
                array_filter($costs['by_clinic'], static fn($item) => (int) $item['contact_id'] === $contact),
            )[0] ?? null;
        $assert($structure !== null && $structure['label'] === 'Structure test');
        $assert((int) $structure['total_cents'] === 1234);
    });
    $test('La recherche globale retrouve les dossiers accessibles', function () use ($assert): void {
        $animals = GlobalSearchService::search('Animal Test');
        $assert(($animals['animals'][0]['title'] ?? '') === 'Animal Test', 'Animal absent de la recherche');
        $contacts = GlobalSearchService::search('Structure test');
        $assert(($contacts['directory'][0]['title'] ?? '') === 'Structure test', 'Contact absent de la recherche');
        $agenda = GlobalSearchService::search('Mission bénévole');
        $assert(!empty($agenda['agenda']), 'Événement absent de la recherche');
    });
    $test('Une sauvegarde ZIP contient une base et un manifeste', function () use (&$createdBackup, $assert): void {
        if (!class_exists('ZipArchive')) {
            return;
        }
        $createdBackup = BackupService::create('test-suite');
        $path = BackupService::path($createdBackup['name']);
        $assert($path !== null);
        $zip = new ZipArchive();
        $assert($zip->open($path) === true);
        $assert($zip->locateName('manifest.json') !== false);
        $assert($zip->locateName('database/refuge.sqlite') !== false);
        $zip->close();
    });
    $test('La sauvegarde créée est relue et son SQLite vérifié', function () use (&$createdBackup, $assert): void {
        if (!$createdBackup) {
            return;
        }
        $result = BackupService::verify($createdBackup['name']);
        $assert($result['valid'] === true);
        $assert($result['files'] >= 1);
    });
    $test('La pharmacie refuse un stock négatif et valorise l’animal', function () use ($db, $assert): void {
        $product = InventoryService::saveProduct([
            'category' => 'medication',
            'name' => 'Produit stock test',
            'unit' => 'comprimé',
            'minimum_quantity' => '2',
        ]);
        $batch = InventoryService::receive([
            'product_id' => $product,
            'quantity' => '10',
            'unit_cost' => '1,20',
            'batch_number' => 'TEST-LOT',
            'expires_on' => date('Y-m-d', strtotime('+1 year')),
        ]);
        $animal = (int) $db->query("SELECT id FROM animals WHERE internal_code='TEST-001'")->fetchColumn();
        InventoryService::consumeBatch($batch, 2, $animal, 'Test automatisé');
        $assert((float) $db->query('SELECT quantity FROM inventory_batches WHERE id=' . $batch)->fetchColumn() === 8.0);
        $assert(
            (int) $db
                ->query(
                    "SELECT total_cents FROM animal_expenses WHERE source_type='inventory' AND source_id=(SELECT MAX(id) FROM inventory_movements WHERE batch_id=$batch)",
                )
                ->fetchColumn() === 240,
        );
        $failed = false;
        try {
            InventoryService::consumeBatch($batch, 20, null, 'Impossible');
        } catch (RuntimeException) {
            $failed = true;
        }
        $assert($failed, 'Le stock négatif a été accepté');
        $assert(
            (int) $db->query('SELECT COUNT(*) FROM inventory_movements WHERE batch_id=' . $batch)->fetchColumn() === 2,
        );
    });
    $test('La portabilité et le registre privé sont opérationnels', function () use ($db, $assert): void {
        if (!class_exists('ZipArchive')) {
            return;
        }
        $db->exec(
            "INSERT INTO directory_contacts(kind,name,email) VALUES('person','Contact RGPD','contact@example.test')",
        );
        $id = (int) $db->lastInsertId();
        $path = PrivacyService::exportContact($id);
        $assert(is_file($path));
        $zip = new ZipArchive();
        $assert($zip->open($path) === true);
        $assert($zip->locateName('contact.json') !== false);
        $zip->close();
        unlink($path);
        PrivacyService::logAccess('test_document', 1, null, 'test.pdf');
        $assert(
            (int) $db
                ->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='test_document'")
                ->fetchColumn() === 1,
        );
    });
    $test('La maintenance RGPD purge uniquement le journal arrivé à échéance', function () use ($db, $assert): void {
        AppSettings::save(['privacy_access_log_months' => '12'], Auth::id());
        $db->exec(
            "INSERT INTO private_file_access_log(resource_type,action,accessed_at) VALUES('ancien_test','view',datetime('now','-13 months'))",
        );
        $result = PrivacyService::maintenance();
        $assert($result['access_logs_removed'] >= 1);
        $assert(
            (int) $db
                ->query("SELECT COUNT(*) FROM private_file_access_log WHERE resource_type='ancien_test'")
                ->fetchColumn() === 0,
        );
        $assert(
            (int) $db->query("SELECT COUNT(*) FROM directory_contacts WHERE name='Contact RGPD'")->fetchColumn() === 1,
            'Un contact a été supprimé automatiquement',
        );
    });
    $test('Les appareils persistants sont révocables', function () use ($db, $assert): void {
        TrustedDeviceService::create((int) Auth::id(), 'Téléphone de test');
        $device = $db->query('SELECT * FROM trusted_devices ORDER BY id DESC LIMIT 1')->fetch();
        $assert($device && $device['device_name'] === 'Téléphone de test');
        TrustedDeviceService::revoke((int) $device['id'], (int) Auth::id());
        $assert(
            (bool) $db->query('SELECT revoked_at FROM trusted_devices WHERE id=' . (int) $device['id'])->fetchColumn(),
        );
    });
    $test('Les vues ne contiennent aucune balise PHP déformée', function () use ($root, $assert): void {
        $views = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/app/Views'));
        foreach ($views as $view) {
            if (!$view->isFile() || $view->getExtension() !== 'php') {
                continue;
            }
            $contents = file_get_contents($view->getPathname());
            $assert($contents !== false, 'Vue illisible : ' . $view->getFilename());
            foreach (['< ? =', '? >', ' - > '] as $brokenToken) {
                $assert(
                    !str_contains($contents, $brokenToken),
                    'Syntaxe déformée dans ' . $view->getFilename() . ' : ' . $brokenToken,
                );
            }
        }
    });
    $test('La démonstration installe une fiche Twix complète', function () use ($db, $root, $assert): void {
        DemoDataService::reset($db);
        $summary = DemoDataService::seed($db, Auth::id());
        DemoDataService::clearFiles();
        DemoDataService::installFiles($db);
        $twix = (int) $db
            ->query("SELECT id FROM animals WHERE internal_code='DEMO-003' AND name='Twix' AND status='quarantaine'")
            ->fetchColumn();
        $assert($twix > 0, 'Twix absent de la quarantaine');
        $assert(
            $summary['lab_reports'] === 1 && $summary['agenda_items'] === 1 && $summary['photos'] === 1,
            'Résumé de démonstration incomplet',
        );
        foreach (
            [
                'medical_notes',
                'treatments',
                'vaccinations',
                'dewormings',
                'animal_health_conditions',
                'lab_reports',
                'animal_photos',
                'agenda_items',
            ]
            as $table
        ) {
            $assert(
                (int) $db->query("SELECT COUNT(*) FROM $table WHERE animal_id=$twix")->fetchColumn() > 0,
                "Donnée Twix absente : $table",
            );
        }
        $assert(is_file($root . '/public/media/animals/' . $twix . '/twix.webp'), 'Photo de Twix absente');
        DemoDataService::clearFiles();
    });
} finally {
    if ($createdBackup && isset($createdBackup['name'])) {
        try {
            BackupService::delete($createdBackup['name']);
        } catch (Throwable) {
        }
    }
    DB::close();
    foreach ([$tmp, $tmp . '-wal', $tmp . '-shm'] as $file) {
        if (is_file($file)) {
            unlink($file);
        }
    }
}
echo "\n$passed réussi(s), $failed échec(s).\n";
exit($failed ? 1 : 0);
