<?php
namespace App\Controllers;

use App\Core\Controller;
use App\Core\Database;
use App\Core\ModuleManager;
use App\Core\Notifier;
use App\Core\PDF;
use App\Core\RaccordementOtbService;
use App\Core\XlsxReader;
use App\Core\Auth;
use PDO;

class MaintenanceFtthController extends Controller
{
    protected PDO $pdo;

    public function __construct()
    {
        $this->pdo = Database::pdo();
        $this->ensureTables();
        ModuleManager::ensureSchema($this->pdo);
        RaccordementOtbService::ensureSchema($this->pdo);

        if (Auth::check()) {
            $this->guardLicensedModuleAccess();
        }
    }

    protected function guardLicensedModuleAccess(): void
    {
        $moduleKey = $this->currentLicensedModuleKey();
        if ($moduleKey === null) {
            return;
        }

        $module = ModuleManager::find($this->pdo, $moduleKey);
        if (!empty($module['accessible'])) {
            return;
        }

        http_response_code(403);
        $this->view('settings/module_blocked', ModuleManager::buildBlockedViewData($module));
        exit;
    }

    protected function currentLicensedModuleKey(): ?string
    {
        $requestUri = (string)($_SERVER['REQUEST_URI'] ?? '');
        if (str_contains($requestUri, '/raccordement-clients')) {
            return 'raccordement_clients';
        }

        return null;
    }

    // ================================================================
    // SCHEMA AUTO-CREATION
    // ================================================================
    private function ensureTables(): void
    {
        $tables = [
            "CREATE TABLE IF NOT EXISTS maintenance_ftth_tickets (
                id INT AUTO_INCREMENT PRIMARY KEY,
                ref_code VARCHAR(80) NULL,
                fixed_line VARCHAR(120) NULL,
                client_name VARCHAR(255) NOT NULL,
                client_code VARCHAR(80) NULL,
                client_phone VARCHAR(60) NULL,
                client_phone2 VARCHAR(60) NULL,
                client_email VARCHAR(150) NULL,
                client_address TEXT NULL,
                client_lat DECIMAL(10,7) NULL,
                client_lng DECIMAL(10,7) NULL,
                sro_client VARCHAR(120) NULL,
                jdv_client VARCHAR(120) NULL,
                pco_client VARCHAR(120) NULL,
                company_name VARCHAR(255) NULL,
                site_name VARCHAR(255) NULL,
                nature_intervention VARCHAR(300) NULL,
                description TEXT NULL,
                cause_comment TEXT NULL,
                priority ENUM('Basse','Moyenne','Haute','Urgent') NOT NULL DEFAULT 'Moyenne',
                status ENUM('nouveau','assigné','en_cours','attente_planification','traité','validé','clôturé') NOT NULL DEFAULT 'nouveau',
                imported_at DATETIME NULL,
                imported_by INT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",

            "CREATE TABLE IF NOT EXISTS maintenance_ftth_assignments (
                id INT AUTO_INCREMENT PRIMARY KEY,
                ticket_id INT NOT NULL,
                technician_id INT NOT NULL,
                assigned_by INT NOT NULL,
                assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                notes TEXT NULL,
                notified_email TINYINT(1) NOT NULL DEFAULT 0,
                notified_app TINYINT(1) NOT NULL DEFAULT 0,
                INDEX idx_ticket (ticket_id),
                INDEX idx_tech (technician_id)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",

            "CREATE TABLE IF NOT EXISTS maintenance_ftth_reports (
                id INT AUTO_INCREMENT PRIMARY KEY,
                ticket_id INT NOT NULL,
                technician_id INT NOT NULL,
                operation_type ENUM('maintenance','travaux','autres') NOT NULL DEFAULT 'maintenance',
                intervention_date DATE NULL,
                intervention_start TIME NULL,
                intervention_end TIME NULL,
                constat_comments TEXT NULL,
                impacted_equipment JSON NULL,
                materials_used JSON NULL,
                general_comments TEXT NULL,
                satisfaction_service VARCHAR(30) NULL,
                satisfaction_delay VARCHAR(30) NULL,
                satisfaction_quality VARCHAR(30) NULL,
                satisfaction_behavior VARCHAR(30) NULL,
                client_name_onsite VARCHAR(200) NULL,
                client_visit_status ENUM('intervention_effectuee','client_indisponible','reprogrammation_demandee') NOT NULL DEFAULT 'intervention_effectuee',
                planned_intervention_date DATE NULL,
                planned_intervention_time TIME NULL,
                planning_comment TEXT NULL,
                client_remarks TEXT NULL,
                service_restored TINYINT(1) NOT NULL DEFAULT 0,
                client_signature_path VARCHAR(350) NULL,
                agent_signature_path VARCHAR(350) NULL,
                submit_gps_lat DECIMAL(10,7) NULL,
                submit_gps_lng DECIMAL(10,7) NULL,
                status ENUM('brouillon','soumis','validé','rejeté') NOT NULL DEFAULT 'brouillon',
                submitted_at DATETIME NULL,
                validated_at DATETIME NULL,
                validated_by INT NULL,
                rejection_reason TEXT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                UNIQUE KEY uniq_ticket (ticket_id)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",

            "CREATE TABLE IF NOT EXISTS maintenance_ftth_import_history (
                id INT AUTO_INCREMENT PRIMARY KEY,
                file_hash CHAR(64) NOT NULL,
                original_name VARCHAR(255) NULL,
                row_count INT NOT NULL DEFAULT 0,
                first_imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                first_imported_by INT NULL,
                last_imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                last_imported_by INT NULL,
                import_count INT NOT NULL DEFAULT 1,
                last_imported_rows INT NOT NULL DEFAULT 0,
                last_skipped_rows INT NOT NULL DEFAULT 0,
                UNIQUE KEY uq_file_hash (file_hash),
                INDEX idx_last_imported_at (last_imported_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",

            "CREATE TABLE IF NOT EXISTS maintenance_ftth_photos (
                id INT AUTO_INCREMENT PRIMARY KEY,
                report_id INT NOT NULL,
                tab_type ENUM('constat','intervention') NOT NULL,
                item_index SMALLINT NULL,
                photo_path VARCHAR(350) NOT NULL,
                comment TEXT NULL,
                gps_lat DECIMAL(10,7) NULL,
                gps_lng DECIMAL(10,7) NULL,
                taken_at DATETIME NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                INDEX idx_report (report_id)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",

            "CREATE TABLE IF NOT EXISTS maintenance_ftth_ticket_events (
                id INT AUTO_INCREMENT PRIMARY KEY,
                ticket_id INT NOT NULL,
                actor_id INT NULL,
                event_type VARCHAR(80) NOT NULL,
                title VARCHAR(190) NOT NULL,
                detail TEXT NULL,
                tone VARCHAR(30) NOT NULL DEFAULT 'secondary',
                icon VARCHAR(50) NOT NULL DEFAULT 'clock-rotate-left',
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                INDEX idx_ticket_created (ticket_id, created_at),
                INDEX idx_actor (actor_id)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        ];

        foreach ($tables as $sql) {
            try { $this->pdo->exec($sql); } catch (\Throwable $e) { /* table already exists */ }
        }

        // Index UNIQUE sur ref_code pour éviter les doublons lors d'un re-import
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD UNIQUE KEY uq_ref_code (ref_code)"
            );
        } catch (\Throwable $e) { /* déjà présent ou ref_code NULL — ignoré */ }

        // Ajouter extra_fields si absent (pour stocker toutes les colonnes Excel brutes)
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD COLUMN extra_fields JSON NULL AFTER description"
            );
        } catch (\Throwable $e) { /* colonne déjà présente */ }

        // Ajouter client_phone2 pour le deuxième contact
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD COLUMN client_phone2 VARCHAR(60) NULL AFTER client_phone"
            );
        } catch (\Throwable $e) { /* déjà présent */ }

        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets MODIFY status ENUM('nouveau','assigné','en_cours','attente_planification','traité','validé','clôturé') NOT NULL DEFAULT 'nouveau'"
            );
        } catch (\Throwable $e) { /* enum déjà à jour */ }

        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_reports ADD COLUMN client_visit_status ENUM('intervention_effectuee','client_indisponible','reprogrammation_demandee') NOT NULL DEFAULT 'intervention_effectuee' AFTER client_name_onsite"
            );
        } catch (\Throwable $e) { /* colonne déjà présente */ }

        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_reports ADD COLUMN planned_intervention_date DATE NULL AFTER client_visit_status"
            );
        } catch (\Throwable $e) { /* colonne déjà présente */ }

        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_reports ADD COLUMN planned_intervention_time TIME NULL AFTER planned_intervention_date"
            );
        } catch (\Throwable $e) { /* colonne déjà présente */ }

        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_reports ADD COLUMN planning_comment TEXT NULL AFTER planned_intervention_time"
            );
        } catch (\Throwable $e) { /* colonne déjà présente */ }
        foreach ([
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN fixed_line VARCHAR(120) NULL AFTER ref_code",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN public_client_id INT NULL AFTER client_email",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN sro_client VARCHAR(120) NULL AFTER client_lng",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN jdv_client VARCHAR(120) NULL AFTER sro_client",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN pco_client VARCHAR(120) NULL AFTER jdv_client",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN company_name VARCHAR(255) NULL AFTER pco_client",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN source_channel VARCHAR(40) NULL AFTER company_name",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN last_public_incident_at DATETIME NULL AFTER source_channel",
            "ALTER TABLE maintenance_ftth_tickets ADD COLUMN cause_comment TEXT NULL AFTER description"
        ] as $alterSql) {
            try {
                $this->pdo->exec($alterSql);
            } catch (\Throwable $e) { /* déjà présent */ }
        }

        // Ajouter avancement / equipes Excel
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD COLUMN avancement VARCHAR(100) NULL AFTER nature_intervention"
            );
        } catch (\Throwable $e) { /* déjà présent */ }
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD COLUMN equipes VARCHAR(200) NULL AFTER avancement"
            );
        } catch (\Throwable $e) { /* déjà présent */ }
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets ADD COLUMN date_cloture VARCHAR(60) NULL AFTER equipes"
            );
        } catch (\Throwable $e) { /* déjà présent */ }

        $this->ensureMaintenanceTicketIdIntegrity();
    }

    private function ensureMaintenanceTicketIdIntegrity(): void
    {
        try {
            $this->pdo->exec(
                "ALTER TABLE maintenance_ftth_tickets MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT"
            );
        } catch (\Throwable $e) {
            // La colonne peut déjà être conforme ou non modifiable selon les droits SQL.
        }

        try {
            $zeroIdCount = (int)($this->pdo->query("SELECT COUNT(*) FROM maintenance_ftth_tickets WHERE id = 0")->fetchColumn() ?: 0);
            if ($zeroIdCount <= 0) {
                return;
            }

            $this->pdo->beginTransaction();
            try {
                $nextId = (int)($this->pdo->query("SELECT COALESCE(MAX(id), 0) + 1 FROM maintenance_ftth_tickets WHERE id <> 0")->fetchColumn() ?: 1);

                // Répare chaque ticket id=0, même en cas d'import massif.
                $iterations = 0;
                while (true) {
                    $updated = $this->pdo->prepare("UPDATE maintenance_ftth_tickets SET id = ? WHERE id = 0 LIMIT 1");
                    $updated->execute([$nextId]);

                    if ((int)$updated->rowCount() !== 1) {
                        break;
                    }

                    $nextId++;
                    $iterations++;
                    if ($iterations > 50000) {
                        break;
                    }
                }

                // Mapping fiable possible uniquement s'il n'y avait qu'un seul ticket id=0.
                if ($zeroIdCount === 1) {
                    $repairedId = $nextId - 1;
                    $this->pdo->prepare("UPDATE maintenance_ftth_assignments SET ticket_id = ? WHERE ticket_id = 0")
                        ->execute([$repairedId]);
                    $this->pdo->prepare("UPDATE maintenance_ftth_reports SET ticket_id = ? WHERE ticket_id = 0")
                        ->execute([$repairedId]);
                    $this->pdo->prepare("UPDATE maintenance_ftth_ticket_events SET ticket_id = ? WHERE ticket_id = 0")
                        ->execute([$repairedId]);
                }

                $this->pdo->commit();
            } catch (\Throwable $e) {
                if ($this->pdo->inTransaction()) {
                    $this->pdo->rollBack();
                }
                throw $e;
            }
        } catch (\Throwable $e) {
            // Ne pas bloquer l'application si la réparation ne peut pas s'exécuter.
        }
    }

    protected function normalizeClientVisitStatus(?string $value): string
    {
        $allowed = ['intervention_effectuee', 'client_indisponible', 'reprogrammation_demandee'];
        $value = trim((string)$value);

        return in_array($value, $allowed, true) ? $value : 'intervention_effectuee';
    }

    protected function normalizeOptionalDateValue($value): ?string
    {
        $value = trim((string)$value);
        if ($value === '') {
            return null;
        }

        $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
        return $date ? $date->format('Y-m-d') : null;
    }

    protected function normalizeOptionalTimeValue($value): ?string
    {
        $value = trim((string)$value);
        if ($value === '') {
            return null;
        }

        foreach (['H:i:s', 'H:i'] as $format) {
            $time = \DateTimeImmutable::createFromFormat($format, $value);
            if ($time) {
                return $time->format('H:i:s');
            }
        }

        return null;
    }

    protected function currentModuleBasePath(): string
    {
        if (str_contains((string)($_SERVER['REQUEST_URI'] ?? ''), '/raccordement-clients')) {
            return '/raccordement-clients';
        }

        return '/maintenance-ftth';
    }

    protected function currentValidationTechnicianType(): string
    {
        return $this->isRaccordementClientsRequest() ? 'etude_raccordement' : 'maintenance_ftth';
    }

    protected function normalizeUserTechnicianType(?string $value): string
    {
        return strtolower(trim((string)$value));
    }

    protected function normalizeAssignedCityValue(?string $value): ?string
    {
        $normalized = mb_strtolower(trim((string)$value));
        return $normalized !== '' ? $normalized : null;
    }

    protected function currentUserAssignedCity(?array $user = null): ?string
    {
        $user ??= Auth::user() ?? [];
        return $this->normalizeAssignedCityValue((string)($user['assigned_city'] ?? ''));
    }

    protected function maintenanceAssignedCityClause(string $alias = 't'): string
    {
        return '('
            . 'LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(' . $alias . '.extra_fields, "$.Ville")), ""))) = ?'
            . ' OR LOWER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(' . $alias . '.extra_fields, "$.Commune")), ""))) = ?'
            . ' OR LOWER(TRIM(COALESCE(' . $alias . '.site_name, ""))) = ?'
            . ' OR LOWER(COALESCE(' . $alias . '.client_address, "")) LIKE ?'
            . ')';
    }

    protected function appendAssignedCityFilter(array &$where, array &$params, ?array $user = null, string $alias = 't'): void
    {
        $assignedCity = $this->currentUserAssignedCity($user);
        if ($assignedCity === null) {
            return;
        }

        $where[] = $this->maintenanceAssignedCityClause($alias);
        $params[] = $assignedCity;
        $params[] = $assignedCity;
        $params[] = $assignedCity;
        $params[] = '%' . $assignedCity . '%';
    }

    protected function extractMaintenanceTicketCity(array $ticket): ?string
    {
        $extraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode((string)$ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $extraFields = $decoded;
            }
        }

        foreach (['Ville', 'Commune'] as $field) {
            $value = $this->normalizeAssignedCityValue((string)($extraFields[$field] ?? ''));
            if ($value !== null) {
                return $value;
            }
        }

        return $this->normalizeAssignedCityValue((string)($ticket['site_name'] ?? ''));
    }

    protected function ticketMatchesAssignedCity(array $ticket, ?array $user = null): bool
    {
        $assignedCity = $this->currentUserAssignedCity($user);
        if ($assignedCity === null) {
            return true;
        }

        $ticketCity = $this->extractMaintenanceTicketCity($ticket);
        if ($ticketCity !== null && $ticketCity === $assignedCity) {
            return true;
        }

        $address = $this->normalizeAssignedCityValue((string)($ticket['client_address'] ?? ''));
        return $address !== null && str_contains($address, $assignedCity);
    }

    protected function resolveMaintenanceTicketCity(int $ticketId): ?string
    {
        if ($ticketId <= 0) {
            return null;
        }

        $statement = $this->pdo->prepare('SELECT extra_fields, site_name FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $statement->execute([$ticketId]);
        $ticket = $statement->fetch(PDO::FETCH_ASSOC) ?: null;

        return $ticket ? $this->extractMaintenanceTicketCity($ticket) : null;
    }

    protected function canAccessMaintenanceTicket(int $ticketId, array $user, string $action = 'view'): bool
    {
        if ($ticketId <= 0) {
            return false;
        }

        $scope = $this->resolveMaintenanceScope($action, $user);
        if ($scope === 'none') {
            return false;
        }

        $where = ['t.id = ?'];
        $params = [$ticketId];
        $this->appendAssignedCityFilter($where, $params, $user, 't');

        if ($scope === 'own') {
            $where[] = $this->maintenanceOwnershipClause('t');
            $params[] = (int)($user['id'] ?? 0);
            $params[] = (int)($user['id'] ?? 0);
        }

        $statement = $this->pdo->prepare('SELECT 1 FROM maintenance_ftth_tickets t WHERE ' . implode(' AND ', $where) . ' LIMIT 1');
        $statement->execute($params);

        return (bool)$statement->fetchColumn();
    }

    protected function requestMatchesAssignedCity(array $user, ?string $city = null, ?string $commune = null, ?string $address = null): bool
    {
        $assignedCity = $this->currentUserAssignedCity($user);
        if ($assignedCity === null) {
            return true;
        }

        foreach ([$city, $commune] as $candidate) {
            if ($this->normalizeAssignedCityValue($candidate) === $assignedCity) {
                return true;
            }
        }

        $normalizedAddress = $this->normalizeAssignedCityValue($address);
        return $normalizedAddress !== null && str_contains($normalizedAddress, $assignedCity);
    }

    protected function canUserValidateCurrentModule(array $user): bool
    {
        $roleKey = strtolower(trim((string)($user['role_key'] ?? '')));
        if (in_array($roleKey, ['admin', 'manager'], true)) {
            return true;
        }

        if (!in_array($roleKey, ['agent', 'superviseur', 'supervisor'], true)) {
            return false;
        }

        return $this->normalizeUserTechnicianType($user['technician_type'] ?? null) === $this->currentValidationTechnicianType();
    }

    protected function resolveValidationRecipientIds(?int $ticketId = null): array
    {
        $ticketCity = $ticketId !== null && $ticketId > 0 ? $this->resolveMaintenanceTicketCity($ticketId) : null;
        $typedRecipientsStatement = $this->pdo->prepare(
            "SELECT id, assigned_city
             FROM users
             WHERE active = 1
               AND role_key IN ('agent','superviseur','supervisor','admin','manager')
               AND technician_type = ?
             ORDER BY name ASC"
        );
        $typedRecipientsStatement->execute([$this->currentValidationTechnicianType()]);
        $typedRecipients = [];
        foreach ($typedRecipientsStatement->fetchAll(PDO::FETCH_ASSOC) ?: [] as $recipient) {
            $recipientCity = $this->normalizeAssignedCityValue((string)($recipient['assigned_city'] ?? ''));
            if ($ticketCity !== null && $recipientCity !== null && $recipientCity !== $ticketCity) {
                continue;
            }
            $typedRecipients[] = (int)($recipient['id'] ?? 0);
        }
        $typedRecipients = array_values(array_filter($typedRecipients));
        if (!empty($typedRecipients)) {
            return $typedRecipients;
        }

        $fallbackRecipients = $this->pdo->query(
            "SELECT id, assigned_city
             FROM users
             WHERE active = 1
               AND role_key IN ('admin','manager')
             ORDER BY name ASC"
        )->fetchAll(PDO::FETCH_ASSOC);

        $filteredFallbackRecipients = [];
        foreach ($fallbackRecipients ?: [] as $recipient) {
            $recipientCity = $this->normalizeAssignedCityValue((string)($recipient['assigned_city'] ?? ''));
            if ($ticketCity !== null && $recipientCity !== null && $recipientCity !== $ticketCity) {
                continue;
            }
            $filteredFallbackRecipients[] = (int)($recipient['id'] ?? 0);
        }

        return array_values(array_filter($filteredFallbackRecipients));
    }

    protected function detailPathForModule(int $ticketId, ?string $moduleBasePath = null): string
    {
        $moduleBasePath = $moduleBasePath ?: $this->currentModuleBasePath();
        return $moduleBasePath . '/detail?id=' . $ticketId;
    }

    private function resolveRaccordementZoneTeamTechnicianIds(array $ticket): array
    {
        $zoneKey = trim((string)($ticket['dispatch_zone_key'] ?? ''));
        if ($zoneKey === '') {
            return [];
        }

        $setting = RaccordementOtbService::getZoneSetting($this->pdo, $zoneKey);
        return array_values(array_filter(array_map('intval', $setting['technician_ids'] ?? []), static fn(int $id): bool => $id > 0));
    }

    private function fetchAssignedTechnicianIds(int $ticketId, ?int $excludeTechnicianId = null): array
    {
        if ($ticketId <= 0) {
            return [];
        }

        $stmt = $this->pdo->prepare('SELECT technician_id FROM maintenance_ftth_assignments WHERE ticket_id = ? ORDER BY assigned_at ASC');
        $stmt->execute([$ticketId]);
        $ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []);

        if ($excludeTechnicianId !== null && $excludeTechnicianId > 0) {
            $ids = array_values(array_filter($ids, static fn(int $id): bool => $id !== $excludeTechnicianId));
        }

        return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
    }

    private function resolveRaccordementHandoffTechnicianIds(array $ticket, int $ticketId, int $currentUserId): array
    {
        $zoneTeamIds = $this->resolveRaccordementZoneTeamTechnicianIds($ticket);
        if (!empty($zoneTeamIds)) {
            return $zoneTeamIds;
        }

        $assignedIds = $this->fetchAssignedTechnicianIds($ticketId, $currentUserId);
        if (!empty($assignedIds)) {
            return $assignedIds;
        }

        // Fallback 1 : superviseurs étude & raccordement (peuvent recevoir la transmission pour validation)
        $validationIds = $this->resolveValidationRecipientIds($ticketId);
        if (!empty($validationIds)) {
            return $validationIds;
        }

        // Fallback 2 (élargi) : tous utilisateurs actifs ayant un profil etude ou etude_raccordement
        $broadSt = $this->pdo->prepare(
            "SELECT id FROM users WHERE active = 1 AND technician_type IN ('etude', 'etude_raccordement') ORDER BY name ASC"
        );
        $broadSt->execute();
        return array_values(array_filter(array_map('intval', $broadSt->fetchAll(\PDO::FETCH_COLUMN) ?: []), static fn(int $id): bool => $id > 0));
    }

    private function resolveRaccordementHandoffTechnicians(array $ticket, int $ticketId, int $excludeTechnicianId = 0): array
    {
        $ids = $this->resolveRaccordementHandoffTechnicianIds($ticket, $ticketId, $excludeTechnicianId);
        $technicians = [];

        foreach ($ids as $technicianId) {
            if ($excludeTechnicianId > 0 && (int)$technicianId === $excludeTechnicianId) {
                continue;
            }

            $user = $this->findUserById((int)$technicianId);
            if ($user) {
                $technicians[] = $user;
            }
        }

        return $technicians;
    }

    protected function findUserById(int $userId): ?array
    {
        if ($userId <= 0) {
            return null;
        }

        $st = $this->pdo->prepare('SELECT id, name, email FROM users WHERE id = ? LIMIT 1');
        $st->execute([$userId]);
        $user = $st->fetch(PDO::FETCH_ASSOC);
        return is_array($user) ? $user : null;
    }

    private function canEditRaccordementStudyPhase(array $user, ?array $report): bool
    {
        if (in_array($user['role_key'] ?? '', ['admin', 'agent', 'manager', 'superviseur', 'supervisor'], true)) {
            return true;
        }

        if (is_array($report) && (string)($report['workflow_phase'] ?? 'study') !== 'study') {
            return false;
        }

        $studyTechnicianId = (int)($report['study_technician_id'] ?? 0);
        if ($studyTechnicianId > 0) {
            return $studyTechnicianId === (int)($user['id'] ?? 0);
        }

        return true;
    }

    private function canEditRaccordementInstallationPhase(array $user, ?array $report): bool
    {
        if (in_array($user['role_key'] ?? '', ['admin', 'agent', 'manager', 'superviseur', 'supervisor'], true)) {
            return true;
        }

        if (!is_array($report) || (string)($report['workflow_phase'] ?? 'study') === 'study') {
            return false;
        }

        $installationTechnicianId = (int)($report['installation_technician_id'] ?? 0);
        if ($installationTechnicianId > 0) {
            return $installationTechnicianId === (int)($user['id'] ?? 0);
        }

        return false;
    }

    protected function resolveFtthWorkflow(string $clientVisitStatus, string $actionType): array
    {
        if (in_array($clientVisitStatus, ['client_indisponible', 'reprogrammation_demandee'], true)) {
            return [
                'ticket_status' => 'attente_planification',
                'report_status' => 'brouillon',
                'mark_submitted' => false,
            ];
        }

        if ($actionType === 'handoff') {
            return [
                'ticket_status' => 'attente_planification',
                'report_status' => 'soumis',
                'mark_submitted' => true,
            ];
        }

        return [
            'ticket_status' => $actionType === 'submit' ? 'traité' : 'en_cours',
            'report_status' => $actionType === 'submit' ? 'soumis' : 'brouillon',
            'mark_submitted' => $actionType === 'submit',
        ];
    }

    protected function notifyAssignmentOwnersForPlanningUpdate(int $ticketId, array $actor, string $clientVisitStatus, ?string $plannedDate, ?string $plannedTime, ?string $planningComment = null): void
    {
        $moduleBasePath = $this->currentModuleBasePath();
        $ownerSt = $this->pdo->prepare(
            'SELECT DISTINCT assigned_by FROM maintenance_ftth_assignments WHERE ticket_id=? AND assigned_by IS NOT NULL'
        );
        $ownerSt->execute([$ticketId]);
        $ownerIds = array_values(array_unique(array_filter(array_map('intval', $ownerSt->fetchAll(PDO::FETCH_COLUMN) ?: []))));

        if (empty($ownerIds)) {
            return;
        }

        $ticketSt = $this->pdo->prepare('SELECT client_name, ref_code FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticket = $ticketSt->fetch(PDO::FETCH_ASSOC) ?: [];

        $ticketLabel = trim((string)($ticket['ref_code'] ?? '')) ?: ('Ticket #' . $ticketId);
        $clientName = trim((string)($ticket['client_name'] ?? 'client'));
        $actorName = trim((string)($actor['name'] ?? 'Le technicien'));

        if ($clientVisitStatus === 'client_indisponible') {
            $title = '[FTTH] Client indisponible';
            $body = $actorName . ' a indiqué que le client ' . $clientName . ' est indisponible pour ' . $ticketLabel . '. Ticket placé en attente de planification.';
        } else {
            $schedule = $plannedDate ? date('d/m/Y', strtotime($plannedDate)) : 'date à confirmer';
            if ($plannedTime) {
                $schedule .= ' à ' . substr($plannedTime, 0, 5);
            }
            $title = '[FTTH] Intervention à replanifier';
            $body = $actorName . ' a demandé une replanification pour ' . $clientName . ' (' . $ticketLabel . ') au ' . $schedule . '.';
        }

        if ($planningComment) {
            $body .= ' Motif : ' . trim($planningComment);
        }

        Notifier::notifyUsers(
            $this->pdo,
            $ownerIds,
            $title,
            $body,
            $this->detailPathForModule($ticketId, $moduleBasePath)
        );
    }

    // ================================================================
    // HELPERS
    // ================================================================
    protected function jsonOk(array $data, int $code = 200): void
    {
        http_response_code($code);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }

    protected function canEditWebReportForTicket(int $ticketId, array $user): bool
    {
        if ($ticketId <= 0) {
            return false;
        }

        $ticketSt = $this->pdo->prepare('SELECT status FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticketStatus = $ticketSt->fetchColumn();
        if (!$ticketStatus || !in_array($ticketStatus, ['assigné', 'en_cours'], true)) {
            return false;
        }

        if (in_array($user['role_key'] ?? '', ['admin','agent','manager','superviseur','supervisor'], true)) {
            return true;
        }

        $chk = $this->pdo->prepare(
            'SELECT 1 FROM maintenance_ftth_assignments WHERE ticket_id=? AND technician_id=? LIMIT 1'
        );
        $chk->execute([$ticketId, (int)$user['id']]);
        return (bool)$chk->fetchColumn();
    }

    protected function ensureWebReportDraftExists(int $ticketId, int $technicianId): int
    {
        $existing = $this->pdo->prepare(
            'SELECT id
             FROM maintenance_ftth_reports
             WHERE ticket_id = ? AND id > 0
             ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
             LIMIT 1'
        );
        $existing->execute([$ticketId]);
        $existingId = (int)($existing->fetchColumn() ?: 0);
        if ($existingId > 0) {
            return $existingId;
        }

        // Supprimer toute ligne parasite (id=0) avant l'INSERT
        // (la contrainte UNIQUE ticket_id bloquerait l'insertion silencieusement)
        $this->pdo->prepare('DELETE FROM maintenance_ftth_reports WHERE ticket_id = ? AND id <= 0')->execute([$ticketId]);

        $this->pdo->prepare(
            'INSERT INTO maintenance_ftth_reports (ticket_id, technician_id, status) VALUES (?,?,\'brouillon\')'
        )->execute([$ticketId, $technicianId]);

        return (int)$this->pdo->lastInsertId();
    }

    protected function sanitizeReportNamedItems(array $items, bool $includeQuantity = false): array
    {
        $sanitized = [];
        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }

            $name = trim((string)($item['name'] ?? ''));
            if ($name === '') {
                continue;
            }

            $row = ['name' => $name];
            if ($includeQuantity) {
                $qty = trim((string)($item['qty'] ?? ''));
                if ($qty !== '') {
                    $row['qty'] = $qty;
                }
            }
            $sanitized[] = $row;
        }

        return $sanitized;
    }

    protected function fetchReportItemSuggestions(string $kind, string $query, int $limit = 8): array
    {
        $column = $kind === 'material' ? 'materials_used' : 'impacted_equipment';
        $normalizedQuery = $this->normalizeImportHeader($query);
        if ($normalizedQuery === '') {
            return [];
        }

        $rows = $this->pdo->query(
            "SELECT {$column} FROM maintenance_ftth_reports WHERE {$column} IS NOT NULL ORDER BY updated_at DESC LIMIT 250"
        )->fetchAll(PDO::FETCH_COLUMN) ?: [];

        $matches = [];
        $seen = [];

        foreach ($rows as $rowValue) {
            $decoded = json_decode((string)$rowValue, true);
            if (!is_array($decoded)) {
                continue;
            }

            foreach ($decoded as $item) {
                if (!is_array($item)) {
                    continue;
                }

                $name = trim((string)($item['name'] ?? ''));
                if ($name === '') {
                    continue;
                }

                $normalizedName = $this->normalizeImportHeader($name);
                if ($normalizedName === '' || isset($seen[$normalizedName])) {
                    continue;
                }
                if (!str_contains($normalizedName, $normalizedQuery)) {
                    continue;
                }

                $seen[$normalizedName] = true;
                $matches[] = $name;
                if (count($matches) >= $limit) {
                    return $matches;
                }
            }
        }

        return $matches;
    }

    protected function requireRoles(array $roles): array
    {
        $u = $this->requireAuth();
        if (!in_array($u['role_key'] ?? '', $roles, true)) {
            http_response_code(403);
            exit('Accès refusé.');
        }
        return $u;
    }

    protected function requireAuth(): array
    {
        if (!Auth::check()) {
            $this->redirect('/login');
        }

        return Auth::user() ?? [];
    }

    protected function resolveMaintenanceScope(string $action, ?array $user = null): string
    {
        $scope = Auth::permissionScope('maintenance_ftth', $action);
        if ($scope !== 'none') {
            return $scope;
        }

        $user ??= Auth::user() ?? [];
        $roleKey = (string)($user['role_key'] ?? '');

        if (in_array($roleKey, ['admin', 'agent', 'manager', 'superviseur', 'supervisor'], true)) {
            return 'all';
        }

        if (in_array($roleKey, ['technicien', 'technician'], true)) {
            return 'own';
        }

        return 'none';
    }

    protected function maintenanceOwnershipClause(string $alias = 't'): string
    {
        return '(' . $alias . '.imported_by = ? OR EXISTS (SELECT 1 FROM maintenance_ftth_assignments a WHERE a.ticket_id=' . $alias . '.id AND a.technician_id=?))';
    }

    protected function userOwnsMaintenanceTicket(int $ticketId, array $user): bool
    {
        if ($ticketId <= 0) {
            return false;
        }

        $where = ['t.id = ?', $this->maintenanceOwnershipClause('t')];
        $params = [$ticketId, (int)($user['id'] ?? 0), (int)($user['id'] ?? 0)];
        $this->appendAssignedCityFilter($where, $params, $user, 't');

        $stmt = $this->pdo->prepare(
            'SELECT 1
             FROM maintenance_ftth_tickets t
             WHERE ' . implode(' AND ', $where) . '
             LIMIT 1'
        );
        $stmt->execute($params);

        return (bool)$stmt->fetchColumn();
    }

    private function filterAccessibleMaintenanceTicketIds(array $ticketIds, array $user, string $scope): array
    {
        $normalizedIds = array_values(array_unique(array_filter(array_map('intval', $ticketIds), static fn(int $id): bool => $id > 0)));
        if (empty($normalizedIds)) {
            return $normalizedIds;
        }

        if (!in_array($scope, ['all', 'own'], true)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($normalizedIds), '?'));
        $where = ['t.id IN (' . $placeholders . ')'];
        $params = $normalizedIds;
        if ($scope === 'own') {
            $where[] = $this->maintenanceOwnershipClause('t');
            $userId = (int)($user['id'] ?? 0);
            $params[] = $userId;
            $params[] = $userId;
        }
        $this->appendAssignedCityFilter($where, $params, $user, 't');

        $sql = 'SELECT t.id
                FROM maintenance_ftth_tickets t
                WHERE ' . implode(' AND ', $where);
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);

        return array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN) ?: []);
    }

    // ---- Mobile API bearer token auth ----
    private function getBearerToken(): ?string
    {
        $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
        if (!$hdr && function_exists('apache_request_headers')) {
            $apacheHeaders = apache_request_headers();
            $hdr = $apacheHeaders['Authorization'] ?? $apacheHeaders['authorization'] ?? '';
        }
        if ($hdr && preg_match('/^Bearer\s+(.+)$/i', trim($hdr), $m)) {
            return trim($m[1]);
        }
        return null;
    }

    private function findMobileUser(): ?array
    {
        $token = $this->getBearerToken();
        if (!$token) return null;
        try {
            $hash = hash('sha256', $token);
            $st = $this->pdo->prepare(
                'SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked_at IS NULL
                 AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1'
            );
            $st->execute([$hash]);
            $uid = (int)($st->fetchColumn() ?: 0);
            if ($uid <= 0) return null;
            $this->pdo->prepare('UPDATE api_tokens SET last_used_at=NOW() WHERE token_hash=?')->execute([$hash]);
            $u = $this->pdo->prepare('SELECT id,name,email,role_key,technician_type,assigned_city FROM users WHERE id=? AND active=1 LIMIT 1');
            $u->execute([$uid]);
            return $u->fetch(PDO::FETCH_ASSOC) ?: null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    private function resolveMobileTechnicianType(array $user): string
    {
        $technicianType = $this->normalizeUserTechnicianType($user['technician_type'] ?? null);
        $normalizedType = str_replace([' ', '-'], '_', $technicianType);
        $normalizedType = preg_replace('/_+/', '_', $normalizedType) ?: $normalizedType;

        if (in_array($normalizedType, ['etude', 'etude_raccordement', 'raccordement', 'raccordement_clients'], true)) {
            return 'raccordement';
        }

        if (in_array($normalizedType, ['maintenance_ftth', 'maintenanceftth', 'ftth_maintenance'], true)) {
            return 'maintenance_ftth';
        }

        if (in_array($normalizedType, ['backbone', 'backbones'], true)) {
            return 'backbones';
        }

        return $normalizedType;
    }

    private function mobileUserCanAccessFtthPortfolio(array $user): bool
    {
        return $this->resolveMobileTechnicianType($user) !== 'backbones';
    }

    private function mobileUserCanAccessNearbyClients(array $user): bool
    {
        return $this->resolveMobileTechnicianType($user) === 'maintenance_ftth';
    }

    private function mobileUserTicketMatchesPortfolio(array $ticket, array $user): bool
    {
        $technicianType = $this->resolveMobileTechnicianType($user);
        if ($technicianType === 'backbones') {
            return false;
        }

        if ($technicianType === 'raccordement') {
            return $this->ticketMatchesModule($ticket, true);
        }

        if ($technicianType === 'maintenance_ftth') {
            return $this->ticketMatchesModule($ticket, false);
        }

        return true;
    }

    private function mobileUserHasAssignedTicketAccess(int $ticketId, array $user): bool
    {
        if ($ticketId <= 0) {
            return false;
        }

        $check = $this->pdo->prepare(
            'SELECT 1 FROM maintenance_ftth_assignments WHERE ticket_id = ? AND technician_id = ? LIMIT 1'
        );
        $check->execute([$ticketId, (int)($user['id'] ?? 0)]);

        return (bool)$check->fetchColumn();
    }

    protected function uploadDir(int $ticketId): string
    {
        $dir = __DIR__ . '/../../public/storage/uploads/ftth/' . $ticketId . '/';
        if (!is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        return $dir;
    }

    protected function uploadPath(int $ticketId): string
    {
        return 'ftth/' . $ticketId . '/';
    }

    protected function saveUploadedFile(string $field, string $destDir, string $prefix = ''): ?string
    {
        if (empty($_FILES[$field]['tmp_name']) || !is_uploaded_file($_FILES[$field]['tmp_name'])) {
            return null;
        }
        $ext  = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
        $allowed = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
        if (!in_array($ext, $allowed, true)) return null;
        $filename = ($prefix ?: $field) . '_' . uniqid() . '.' . $ext;
        move_uploaded_file($_FILES[$field]['tmp_name'], $destDir . $filename);
        return $filename;
    }

    protected function saveBase64Image(string $b64data, string $destDir, string $prefix): ?string
    {
        if (empty($b64data)) return null;
        if (preg_match('/^data:image\/(\w+);base64,/', $b64data, $m)) {
            $ext  = strtolower($m[1]);
            $data = base64_decode(substr($b64data, strpos($b64data, ',') + 1));
        } else {
            $ext  = 'png';
            $data = base64_decode($b64data);
        }
        if (!$data) return null;
        $filename = $prefix . '_' . uniqid() . '.' . $ext;
        file_put_contents($destDir . $filename, $data);
        return $filename;
    }

    // ================================================================
    // WEB — INDEX (liste des tickets)
    // ================================================================
    public function index(): void
    {
        $u = $this->requireAuth();
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $this->syncAssignmentStatusConsistency();
        $listScope = $this->resolveMaintenanceScope('list', $u);
        if ($listScope === 'none') {
            http_response_code(403);
            exit('Accès refusé.');
        }

        $filters = $this->buildMaintenanceListFilters($u, $listScope, $isRaccordementClients);
        $status   = $filters['status'];
        $search   = $filters['search'];
        $sro      = $filters['sro'];
        $company  = $filters['company'];
        $progress = $filters['progress'];
        $source   = $filters['source'];
        $page     = max(1, (int)($_GET['page'] ?? 1));
        $perPage  = 20;
        $offset   = ($page - 1) * $perPage;
        $whereStr = $filters['where'];
        $params = $filters['params'];

        $countSt = $this->pdo->prepare("SELECT COUNT(*) FROM maintenance_ftth_tickets t WHERE $whereStr");
        $countSt->execute($params);
        $total = (int)$countSt->fetchColumn();

        $listSt = $this->pdo->prepare(
            "SELECT t.*, t.id AS ticket_id,
                    u.name AS imported_by_name,
                    (SELECT GROUP_CONCAT(us.name SEPARATOR ', ')
                     FROM maintenance_ftth_assignments a
                     JOIN users us ON us.id = a.technician_id
                     WHERE a.ticket_id = t.id) AS assigned_to,
                    (SELECT r.status FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS report_status,
                    (SELECT r.client_visit_status FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS client_visit_status,
                    (SELECT r.planned_intervention_date FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS planned_intervention_date,
                    (SELECT r.planned_intervention_time FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS planned_intervention_time,
                    (SELECT COALESCE(r.updated_at, r.created_at) FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id ORDER BY COALESCE(r.updated_at, r.created_at) DESC LIMIT 1) AS report_updated_at
             FROM maintenance_ftth_tickets t
             LEFT JOIN users u ON u.id = t.imported_by
             WHERE $whereStr
             ORDER BY t.created_at DESC
             LIMIT $perPage OFFSET $offset"
        );
        $listSt->execute($params);
        $tickets = $listSt->fetchAll(PDO::FETCH_ASSOC);
        $tickets = array_map(fn(array $ticket): array => $this->hydrateTicketImportFallbacks($ticket), $tickets);

        // Statistiques rapides
        $statsSt = $this->pdo->prepare(
            "SELECT t.status, COUNT(*) AS cnt FROM maintenance_ftth_tickets t WHERE $whereStr GROUP BY t.status"
        );
        $statsSt->execute($params);
        $statsRaw = $statsSt->fetchAll(PDO::FETCH_ASSOC);
        $stats = [];
        foreach ($statsRaw as $r) {
            $stats[$r['status']] = (int)$r['cnt'];
        }

        $technicians = $this->fetchAssignableTechnicians();

        $expectedHeaders = $this->getExpectedImportHeaders($isRaccordementClients);
        $expectedImportFileLabel = $this->getExpectedImportFileLabel($isRaccordementClients);
        $sroOptions = $this->fetchDistinctTicketColumnValues('sro_client', $isRaccordementClients);
        $companyOptions = $this->fetchDistinctTicketColumnValues('company_name', $isRaccordementClients);
        $progressOptions = $this->fetchDistinctTicketColumnValues('avancement', $isRaccordementClients);
        $sourceOptions = [
            'e-intervention' => 'e-Intervention',
            'operator-direct' => 'Opérateur direct',
        ];

        $this->view('maintenance_ftth/index', [
            'title'       => $isRaccordementClients ? 'Raccordement Clients' : 'Maintenance FTTH B2B',
            'isRaccordementClients' => $isRaccordementClients,
            'moduleBasePath' => $this->currentModuleBasePath(),
            'tickets'     => $tickets,
            'stats'       => $stats,
            'status'      => $status,
            'search'      => $search,
            'sro'         => $sro,
            'company'     => $company,
            'progress'    => $progress,
            'source'      => $source,
            'page'        => $page,
            'perPage'     => $perPage,
            'total'       => $total,
            'pages'       => (int)ceil($total / $perPage),
            'technicians' => $technicians,
            'expectedHeaders' => $expectedHeaders,
            'expectedImportFileLabel' => $expectedImportFileLabel,
            'sroOptions'  => $sroOptions,
            'companyOptions' => $companyOptions,
            'progressOptions' => $progressOptions,
            'sourceOptions' => $sourceOptions,
            'user'        => $u,
            'maintenanceScope' => $listScope,
        ]);
    }

    public function export(): void
    {
        $u = $this->requireAuth();
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $listScope = $this->resolveMaintenanceScope('list', $u);
        if ($listScope === 'none') {
            http_response_code(403);
            exit('Accès refusé.');
        }

        $filters = $this->buildMaintenanceListFilters($u, $listScope, $isRaccordementClients);
        $whereStr = $filters['where'];
        $params = $filters['params'];

        $rows = $this->fetchMaintenanceExportRows($whereStr, $params);

        $filename = 'maintenance_ftth_' . date('Ymd_His') . '.csv';
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');

        $output = fopen('php://output', 'w');
        fwrite($output, "\xEF\xBB\xBF");
        fputcsv($output, [
            'Reference', 'Ligne fixe', 'Code client', 'Client', 'Contact 1', 'Contact 2',
            'Localisation', 'SRO', 'JDV', 'PCO', 'Entreprise', 'Nature intervention',
            'Avancement', 'Statut', 'Priorite', 'Equipes', 'Assignes a', 'Statut rapport', 'Statut intervention',
            'Commentaire constat', 'Commentaire intervention',
            'Date cloture', 'Origine', 'Importe le', 'Importe par'
        ], ';');

        foreach ($rows as $row) {
            fputcsv($output, [
                $row['ref_code'] ?? '',
                $row['fixed_line'] ?? '',
                $row['client_code'] ?? '',
                $row['client_name'] ?? '',
                $row['client_phone'] ?? '',
                $row['client_phone2'] ?? '',
                $row['client_address'] ?? '',
                $row['sro_client'] ?? '',
                $row['jdv_client'] ?? '',
                $row['pco_client'] ?? '',
                $row['company_name'] ?? '',
                $row['nature_intervention'] ?? '',
                $row['avancement'] ?? '',
                $row['status'] ?? '',
                $row['priority'] ?? '',
                $row['equipes'] ?? '',
                $row['assigned_to'] ?? '',
                $row['report_status'] ?? '',
                $row['client_visit_status'] ?? '',
                trim((string)($row['constat_comments'] ?? '')),
                trim((string)($row['general_comments'] ?? '')),
                $row['date_cloture'] ?? '',
                (($row['source_channel'] ?? '') === 'e-intervention') ? 'e-Intervention' : 'Operateur direct',
                $row['imported_at'] ?? '',
                $row['imported_by_name'] ?? '',
            ], ';');
        }

        fclose($output);
        exit;
    }

    public function exportPdf(): void
    {
        $u = $this->requireAuth();
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $listScope = $this->resolveMaintenanceScope('list', $u);
        if ($listScope === 'none') {
            http_response_code(403);
            exit('Accès refusé.');
        }

        $filters = $this->buildMaintenanceListFilters($u, $listScope, $isRaccordementClients);
        $rows = $this->fetchMaintenanceExportRows($filters['where'], $filters['params']);
        $stats = $this->buildMaintenanceExportStats($rows);
        $html = $this->renderViewToString('maintenance_ftth/export_pdf', [
            'rows' => $rows,
            'stats' => $stats,
            'filters' => $filters,
            'generatedBy' => $u,
            'generatedAt' => date('Y-m-d H:i:s'),
        ], 'none');

        PDF::download(
            $html,
            ($isRaccordementClients ? 'raccordement_clients_' : 'maintenance_ftth_') . date('Ymd_His') . '.pdf',
            ['isRemoteEnabled' => true]
        );
    }

    private function fetchMaintenanceExportRows(string $whereStr, array $params): array
    {
        $statement = $this->pdo->prepare(
            "SELECT t.*, u.name AS imported_by_name,
                    (SELECT GROUP_CONCAT(us.name SEPARATOR ', ')
                     FROM maintenance_ftth_assignments a
                     JOIN users us ON us.id = a.technician_id
                     WHERE a.ticket_id = t.id) AS assigned_to,
                    (SELECT r.status FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS report_status,
                    (SELECT r.client_visit_status FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS client_visit_status,
                    (SELECT r.constat_comments FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS constat_comments,
                    (SELECT r.general_comments FROM maintenance_ftth_reports r WHERE r.ticket_id = t.id LIMIT 1) AS general_comments
             FROM maintenance_ftth_tickets t
             LEFT JOIN users u ON u.id = t.imported_by
             WHERE $whereStr
             ORDER BY t.created_at DESC"
        );
        $statement->execute($params);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    protected function buildMaintenanceExportStats(array $rows): array
    {
        $stats = [
            'total' => count($rows),
            'open' => 0,
            'validated' => 0,
            'by_status' => [],
        ];

        foreach ($rows as $row) {
            $status = (string)($row['status'] ?? 'nouveau');
            $stats['by_status'][$status] = ($stats['by_status'][$status] ?? 0) + 1;
            if (in_array($status, ['nouveau', 'assigné', 'en_cours'], true)) {
                $stats['open']++;
            }
            if (($row['report_status'] ?? '') === 'validé' || $status === 'validé') {
                $stats['validated']++;
            }
        }

        return $stats;
    }

    private function buildMaintenanceListFilters(array $u, string $listScope, bool $isRaccordementClients = false): array
    {
        $status   = trim($_GET['status'] ?? '');
        $search   = trim($_GET['q'] ?? '');
        $sro      = trim($_GET['sro'] ?? '');
        $company  = trim($_GET['company'] ?? '');
        $progress = trim($_GET['progress'] ?? '');
        $source   = trim($_GET['source'] ?? '');

        $where  = ['1=1'];
        $params = [];

        if ($status !== '') {
            $where[] = 't.status = ?';
            $params[] = $status;
        }
        if ($search !== '') {
            $where[] = '(t.client_name LIKE ? OR t.ref_code LIKE ? OR t.nature_intervention LIKE ? OR t.client_code LIKE ? OR t.fixed_line LIKE ? OR t.company_name LIKE ?)';
            for ($i = 0; $i < 6; $i++) {
                $params[] = "%$search%";
            }
        }
        if ($sro !== '') {
            $where[] = 't.sro_client = ?';
            $params[] = $sro;
        }
        if ($company !== '') {
            $where[] = 't.company_name = ?';
            $params[] = $company;
        }
        if ($progress !== '') {
            $where[] = 't.avancement = ?';
            $params[] = $progress;
        }
        $where[] = $this->maintenanceModuleWhereClause($isRaccordementClients, 't');
        if ($source !== '') {
            if ($source === 'operator-direct') {
                $where[] = "(t.source_channel IS NULL OR t.source_channel = '' OR t.source_channel <> 'e-intervention')";
            } else {
                $where[] = 't.source_channel = ?';
                $params[] = $source;
            }
        }
        if ($listScope === 'own') {
            $where[] = $this->maintenanceOwnershipClause('t');
            $params[] = (int)$u['id'];
            $params[] = (int)$u['id'];
        }
        $this->appendAssignedCityFilter($where, $params, $u, 't');

        return [
            'status' => $status,
            'search' => $search,
            'sro' => $sro,
            'company' => $company,
            'progress' => $progress,
            'source' => $source,
            'where' => implode(' AND ', $where),
            'params' => $params,
        ];
    }

    // ================================================================
    // WEB — IMPORT XLSX
    // ================================================================
    public function import(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = str_contains((string)($_SERVER['REQUEST_URI'] ?? ''), '/raccordement-clients')
            ? '/raccordement-clients'
            : '/maintenance-ftth';

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        if (empty($_FILES['excel_file']['tmp_name']) || !is_uploaded_file($_FILES['excel_file']['tmp_name'])) {
            $this->redirect($moduleBasePath . '?err=nofile');
            return;
        }

        $tmpPath = $_FILES['excel_file']['tmp_name'];
        $originalName = trim((string)($_FILES['excel_file']['name'] ?? ''));
        $origExt = strtolower(pathinfo($_FILES['excel_file']['name'], PATHINFO_EXTENSION));

        if (!in_array($origExt, ['xlsx', 'xls'], true)) {
            $this->redirect($moduleBasePath . '?err=badext');
            return;
        }

        try {
            $rows = XlsxReader::readSheet($tmpPath, 'RACCORDES');
        } catch (\Throwable $e) {
            $this->redirect($moduleBasePath . '?err=' . urlencode($e->getMessage()));
            return;
        }

        if (count($rows) < 2) {
            $this->redirect($moduleBasePath . '?err=empty');
            return;
        }

        $fileHash = @hash_file('sha256', $tmpPath) ?: '';
        $previousImport = $fileHash !== '' ? $this->findExistingImportByHash($fileHash) : null;
        $assocRows = XlsxReader::toAssoc($rows);
        $imported  = 0;
        $skipped   = 0;
        $resolvedSkipped = 0;

        // ON DUPLICATE KEY UPDATE : re-import met à jour les données sans créer de doublon
        $ins = $this->pdo->prepare(
            'INSERT INTO maintenance_ftth_tickets
                         (ref_code, fixed_line, client_name, client_code, client_phone, client_phone2,
                            client_email, client_address, client_lat, client_lng,
                            sro_client, jdv_client, pco_client, company_name,
                            site_name, nature_intervention, description, cause_comment,
                            priority, status, avancement, equipes, date_cloture,
              extra_fields, imported_at, imported_by)
                         VALUES (:ref_code, :fixed_line, :client_name, :client_code, :client_phone, :client_phone2,
                                 :client_email, :client_address, :client_lat, :client_lng,
                                 :sro_client, :jdv_client, :pco_client, :company_name,
                                 :site_name, :nature_intervention, :description, :cause_comment,
                                 :priority, :status, :avancement, :equipes, :date_cloture,
                                 :extra_fields, NOW(), :imported_by)
             ON DUPLICATE KEY UPDATE
                             fixed_line=VALUES(fixed_line), client_name=VALUES(client_name), client_code=VALUES(client_code),
               client_phone=VALUES(client_phone), client_phone2=VALUES(client_phone2),
               client_address=VALUES(client_address), client_lat=VALUES(client_lat),
                             client_lng=VALUES(client_lng), sro_client=VALUES(sro_client),
                             jdv_client=VALUES(jdv_client), pco_client=VALUES(pco_client),
                             company_name=VALUES(company_name), site_name=VALUES(site_name),
                             nature_intervention=VALUES(nature_intervention), description=VALUES(description), cause_comment=VALUES(cause_comment),
                             priority=VALUES(priority), status=VALUES(status), avancement=VALUES(avancement),
               equipes=VALUES(equipes), date_cloture=VALUES(date_cloture),
               extra_fields=VALUES(extra_fields), imported_by=VALUES(imported_by)'
        );

        foreach ($assocRows as $row) {
            $clientName = trim((string)($this->pickCol($row, [
                    'NomClient', 'Nom du client', 'Noom du client', 'Nom client', 'Client', 'Nom / Prenoms client',
                'Numero Abonnement/Nom du client', 'Numero abonnement nom du client'
            ]) ?? ''));
            if ($clientName === '') {
                $skipped++;
                continue;
            }

            $ref = $this->pickCol($row, [
                'Numéro de Requête', 'Numero de Requete', 'Numero Requete', 'N Requete',
                'N° Requête', 'Reference', 'Référence', 'Ref'
            ]);
            $fixedLine = $this->pickCol($row, ['Ligne fixe', 'Ligne Fixe', 'Ligne']);
            $phone = $this->pickCol($row, [
                'Contact1',
                'CONTACT CLIENT 1', 'Contact client 1', 'Contact client', 'Telephone client 1',
                'Téléphone client 1', 'Telephone', 'Téléphone', 'Phone'
            ]);
            $phone2 = $this->pickCol($row, [
                'Contact2',
                'CONTACT CLIENT 2', 'Contact client 2', 'Telephone client 2', 'Téléphone client 2',
                'Phone 2', 'Contact secondaire'
            ]);
            $address = $this->pickCol($row, [
                'LOCALISATION', 'Localisation', 'Adresse', 'Address',
                'Contact client et localisation', 'Contact client localisation', 'Adresse intervention'
            ]);
            $clientCode = $this->pickCol($row, [
                'NumeroAbonnement',
                'Numéro Abonnement', 'Numero Abonnement', 'N° Abonnement', 'Numero abonnement',
                'Code client', 'Abonnement', 'Compte client'
            ]);
            $siteName = $this->pickCol($row, [
                'PLAQUE', 'Commune', 'Ville', 'Site', 'Site concerne', 'Site concerné', 'Nom du site', 'Localisation site'
            ]);
            $sroClient = $this->pickCol($row, ['PLAQUE', 'SRO CLIENT', 'SRO Client', 'SRO']);
            $jdvClient = $this->pickCol($row, ['JDV', 'JDV CLIENT', 'JDV Client']);
            $pcoClient = $this->pickCol($row, ['PCO', 'PCO CLIENT', 'PCO Client']);
            $companyName = $this->pickCol($row, ['EntrepriseFTTH', 'Entreprise/porteur d\'action', 'Entreprise porteur d action', 'Entreprise', 'Porteur d action']);
            $nature = $this->pickCol($row, [
                'Etude_Technique',
                'Nature intervention', 'Nature de l intervention', 'Type intervention',
                'Intervention', 'Type de maintenance'
            ]);
            $desc = $this->pickCol($row, [
                'Commentaires', 'Problèmes',
                'Commentaire', 'Commentaires', 'Commentaire client', 'Observation',
                'Observations', 'Notes', 'Avertissement du client', 'Avertissements du client'
            ]);
            $causeComment = $this->pickCol($row, ['Problèmes', 'Commentaire_cause', 'Commentaire cause', 'Cause', 'Cause commentaire']);
            $avancement = $this->pickCol($row, ['Avancement', 'Progression']);
            $equipes = $this->pickCol($row, [
                'Equipe_raccordement',
                'equipes', 'Équipes', 'Equipe', 'Interruption/equipes', 'Interruption equipes',
                'Liste Tech', 'Liste techniciens', 'Technicien', 'Techniciens', 'Soudeur'
            ]);
            $dateCloture = $this->pickCol($row, [
                'Date de Clôture', 'Date de Cloture', 'Date cloture', 'Cloture', 'Date fermeture'
            ]);
            $rawStatut = $this->pickCol($row, ['Statut', 'Status', 'Etat', 'État']);
            $priority = $this->normalizePriority((string)($this->pickCol($row, [
                'Priorite', 'Priorité', 'Urgence', 'Niveau de priorite', 'Niveau de priorité'
            ]) ?? 'Moyenne'));
            if ($this->shouldSkipImportRowByStatus($rawStatut)) {
                $skipped++;
                $resolvedSkipped++;
                continue;
            }
            $status    = $this->normalizeStatus($rawStatut ?? '');

            // Parse GPS "6.767202, -5.279581"
            $lat = null; $lng = null;
            $gpsRaw = $this->pickCol($row, [
                'GPS', 'Coordonnees GPS', 'Coordonnées GPS', 'Coordonnees', 'Coordonnées',
                'Coordinates', 'Latitude/Longitude'
            ]);
            if ($gpsRaw && preg_match('/(-?\d+(?:[\.,]\d+)?)\s*[,;\/]\s*(-?\d+(?:[\.,]\d+)?)/', (string)$gpsRaw, $m)) {
                $lat = str_replace(',', '.', $m[1]);
                $lng = str_replace(',', '.', $m[2]);
            } else {
                $rawLat = $this->pickCol($row, ['Latitude_Terrai', 'Latitude_Terrain', 'ck_latitude']);
                $rawLng = $this->pickCol($row, ['Longitude_Terrain', 'Longitude_Terrai', 'ck_longitude']);
                if ($rawLat !== null && $rawLng !== null && $rawLat !== '' && $rawLng !== '') {
                    $lat = str_replace(',', '.', (string)$rawLat);
                    $lng = str_replace(',', '.', (string)$rawLng);
                }
            }

            if (trim((string)$nature) === '' && ($this->pickCol($row, ['NumeroAbonnement']) !== null || $this->pickCol($row, ['PLAQUE']) !== null)) {
                $nature = 'Raccordement client';
            }

            $zoneContext = $this->resolveImportZoneContext($row, $sroClient, $jdvClient, $pcoClient, $siteName);

            // Store ALL columns as extra_fields JSON (full fidelity)
            $extra = [];
            foreach ($row as $colName => $colVal) {
                if (trim((string)$colVal) !== '') {
                    $extra[$colName] = $colVal;
                }
            }

            try {
                $ins->execute([
                    'ref_code' => $ref,
                    'fixed_line' => $fixedLine,
                    'client_name' => $clientName,
                    'client_code' => $clientCode,
                    'client_phone' => $phone,
                    'client_phone2' => $phone2,
                    'client_email' => null,
                    'client_address' => $address,
                    'client_lat' => $lat,
                    'client_lng' => $lng,
                    'sro_client' => $sroClient,
                    'jdv_client' => $jdvClient,
                    'pco_client' => $pcoClient,
                    'company_name' => $companyName,
                    'site_name' => $siteName,
                    'nature_intervention' => $nature,
                    'description' => $desc,
                    'cause_comment' => $causeComment,
                    'priority' => $priority,
                    'status' => $status,
                    'avancement' => $avancement,
                    'equipes' => $equipes,
                    'date_cloture' => $dateCloture,
                    'extra_fields' => json_encode($extra, JSON_UNESCAPED_UNICODE),
                    'imported_by' => (int)$u['id'],
                ]);
                $imported++;

                $ticketId = $this->resolveImportedTicketId($ref, $clientCode, $clientName);
                if ($ticketId > 0) {
                    $this->saveTicketDispatchZone($ticketId, $zoneContext);
                }
            } catch (\Throwable $e) {
                $skipped++;
            }
        }

        if ($fileHash !== '') {
            $this->storeImportFingerprint(
                $fileHash,
                $originalName,
                count($assocRows),
                (int)$u['id'],
                $imported,
                $skipped
            );
        }

        $params = [
            'imported' => $imported,
            'skipped' => $skipped,
            'resolved_skipped' => $resolvedSkipped,
        ];

        if ($previousImport) {
            $params['duplicate_import'] = 1;
            $params['duplicate_file'] = (string)($previousImport['original_name'] ?: $originalName);
            $params['duplicate_at'] = (string)($previousImport['last_imported_at'] ?: $previousImport['first_imported_at'] ?: '');
            $params['duplicate_count'] = (int)($previousImport['import_count'] ?? 1);
        }

        $this->redirect($moduleBasePath . '?' . http_build_query($params));
    }

    protected function findExistingImportByHash(string $fileHash): ?array
    {
        if ($fileHash === '') {
            return null;
        }

        try {
            $statement = $this->pdo->prepare(
                'SELECT file_hash, original_name, first_imported_at, last_imported_at, import_count
                   FROM maintenance_ftth_import_history
                  WHERE file_hash = ?
                  LIMIT 1'
            );
            $statement->execute([$fileHash]);
            $row = $statement->fetch(PDO::FETCH_ASSOC);
            return is_array($row) ? $row : null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    protected function storeImportFingerprint(
        string $fileHash,
        string $originalName,
        int $rowCount,
        int $userId,
        int $imported,
        int $skipped
    ): void {
        if ($fileHash === '') {
            return;
        }

        try {
            $statement = $this->pdo->prepare(
                'INSERT INTO maintenance_ftth_import_history
                    (file_hash, original_name, row_count, first_imported_at, first_imported_by, last_imported_at, last_imported_by, import_count, last_imported_rows, last_skipped_rows)
                 VALUES
                    (:file_hash, :original_name, :row_count, NOW(), :first_imported_by, NOW(), :last_imported_by, 1, :last_imported_rows, :last_skipped_rows)
                 ON DUPLICATE KEY UPDATE
                    original_name = VALUES(original_name),
                    row_count = VALUES(row_count),
                    last_imported_at = NOW(),
                    last_imported_by = VALUES(last_imported_by),
                    import_count = import_count + 1,
                    last_imported_rows = VALUES(last_imported_rows),
                    last_skipped_rows = VALUES(last_skipped_rows)'
            );
            $statement->execute([
                'file_hash' => $fileHash,
                'original_name' => $originalName !== '' ? $originalName : null,
                'row_count' => max(0, $rowCount),
                'first_imported_by' => $userId > 0 ? $userId : null,
                'last_imported_by' => $userId > 0 ? $userId : null,
                'last_imported_rows' => max(0, $imported),
                'last_skipped_rows' => max(0, $skipped),
            ]);
        } catch (\Throwable $e) {
            // Ne pas bloquer l'import principal si l'historique n'est pas disponible.
        }
    }

    protected function pickCol(array $row, array $candidates): ?string
    {
        $normalizedRow = [];
        foreach ($row as $key => $val) {
            $normalizedRow[$this->normalizeImportHeader((string)$key)] = is_string($val) ? trim($val) : $val;
        }

        foreach ($candidates as $candidate) {
            $normalizedCandidate = $this->normalizeImportHeader((string)$candidate);
            if ($normalizedCandidate === '') {
                continue;
            }
            if (array_key_exists($normalizedCandidate, $normalizedRow)) {
                $value = $normalizedRow[$normalizedCandidate];
                return trim((string)$value) !== '' ? trim((string)$value) : null;
            }
        }

        foreach ($row as $key => $val) {
            $normalized = $this->normalizeImportHeader((string)$key);
            foreach ($candidates as $c) {
                if ($normalized === $this->normalizeImportHeader((string)$c)) {
                    return trim((string)$val) !== '' ? trim((string)$val) : null;
                }
            }
        }
        // Try partial match as fallback
        foreach ($row as $key => $val) {
            $normalized = $this->normalizeImportHeader((string)$key);
            foreach ($candidates as $c) {
                $normalizedCandidate = $this->normalizeImportHeader((string)$c);
                if ($normalizedCandidate !== ''
                    && (str_contains($normalized, $normalizedCandidate) || str_contains($normalizedCandidate, $normalized))) {
                    return trim((string)$val) !== '' ? trim((string)$val) : null;
                }
            }
        }
        return null;
    }

    protected function hydrateTicketImportFallbacks(array $ticket): array
    {
        $extraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode((string)$ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $extraFields = $decoded;
            }
        }

        if ($extraFields === []) {
            return $ticket;
        }

        $fallbackMap = [
            'sro_client' => ['SRO CLIENT', 'SRO Client', 'SRO'],
            'jdv_client' => ['JDV CLIENT', 'JDV Client', 'JDV'],
            'pco_client' => ['PCO CLIENT', 'PCO Client', 'PCO'],
        ];

        foreach ($fallbackMap as $field => $candidates) {
            $current = trim((string)($ticket[$field] ?? ''));
            if ($current !== '') {
                continue;
            }

            $fallback = $this->pickCol($extraFields, $candidates);
            if ($fallback !== null && trim($fallback) !== '') {
                $ticket[$field] = trim($fallback);
            }
        }

        return $ticket;
    }

    protected function normalizeImportHeader(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
        if ($ascii !== false) {
            $value = $ascii;
        }

        $value = strtolower($value);
        $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? $value;
        return trim($value);
    }

    protected function resolveImportZoneContext(array $row, ?string $sroClient = null, ?string $jdvClient = null, ?string $pcoClient = null, ?string $siteName = null): array
    {
        $plaque = $this->pickCol($row, ['Plaque', 'PLAQUE', 'Zone Plaque', 'Plaque zone']);
        $hub = $this->pickCol($row, ['Hub', 'HUB', 'Zone Hub', 'Hub zone']);
        $zoneLabel = trim((string)($plaque ?: $hub ?: $pcoClient ?: $jdvClient ?: $sroClient ?: $siteName ?: ''));
        $zoneKey = RaccordementOtbService::normalizeZoneKey($zoneLabel);

        return [
            'plaque' => $plaque,
            'hub' => $hub,
            'zone_label' => $zoneLabel,
            'zone_key' => $zoneKey,
        ];
    }

    protected function resolveImportedTicketId(?string $refCode, ?string $clientCode, string $clientName): int
    {
        if (trim((string)$refCode) !== '') {
            $stmt = $this->pdo->prepare('SELECT id FROM maintenance_ftth_tickets WHERE ref_code = ? ORDER BY id DESC LIMIT 1');
            $stmt->execute([trim((string)$refCode)]);
            return (int)($stmt->fetchColumn() ?: 0);
        }

        if (trim((string)$clientCode) !== '') {
            $stmt = $this->pdo->prepare('SELECT id FROM maintenance_ftth_tickets WHERE client_code = ? AND client_name = ? ORDER BY id DESC LIMIT 1');
            $stmt->execute([trim((string)$clientCode), $clientName]);
            return (int)($stmt->fetchColumn() ?: 0);
        }

        $stmt = $this->pdo->prepare('SELECT id FROM maintenance_ftth_tickets WHERE client_name = ? ORDER BY id DESC LIMIT 1');
        $stmt->execute([$clientName]);

        return (int)($stmt->fetchColumn() ?: 0);
    }

    protected function saveTicketDispatchZone(int $ticketId, array $zoneContext): void
    {
        if ($ticketId <= 0) {
            return;
        }

        $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET dispatch_zone_key = ?, dispatch_zone_label = ? WHERE id = ?')
            ->execute([
                $zoneContext['zone_key'] !== '' ? $zoneContext['zone_key'] : null,
                $zoneContext['zone_label'] !== '' ? $zoneContext['zone_label'] : null,
                $ticketId,
            ]);
    }

    protected function autoDispatchTicketByZone(int $ticketId, array $zoneContext, int $assignedBy): void
    {
        $zoneKey = trim((string)($zoneContext['zone_key'] ?? ''));
        if ($ticketId <= 0 || $zoneKey === '') {
            return;
        }

        // Ne pas auto-assigner si le ticket a déjà des assignations (réimport ou assignation manuelle préexistante)
        $existingCount = $this->pdo->prepare('SELECT COUNT(*) FROM maintenance_ftth_assignments WHERE ticket_id = ?');
        $existingCount->execute([$ticketId]);
        if ((int)$existingCount->fetchColumn() > 0) {
            return;
        }

        $setting = RaccordementOtbService::getZoneSetting($this->pdo, $zoneKey);
        $technicianIds = array_values(array_filter(array_map('intval', $setting['technician_ids'] ?? []), static fn(int $id): bool => $id > 0));
        if (empty($technicianIds)) {
            return;
        }

        $this->assignTechniciansToTicket(
            $ticketId,
            $technicianIds,
            $assignedBy,
            'Dispatch automatique zone ' . ($zoneContext['zone_label'] ?: $zoneKey)
        );
    }

    protected function shouldSkipImportRowByStatus(?string $rawStatus): bool
    {
        $normalized = $this->normalizeImportHeader((string)($rawStatus ?? ''));
        if ($normalized === '') {
            return false;
        }

        return preg_match('/(^| )resolu( |$)/', $normalized) === 1;
    }

    protected function normalizeStatus(string $raw): string
    {
        $r = $this->normalizeImportHeader($raw);
        if ($r === '') {
            return 'nouveau';
        }

        if ($r === 'nouveau' || str_contains($r, 'nouveau') || str_contains($r, 'new')) {
            return 'nouveau';
        }

        if (
            str_contains($r, 'attente planification')
            || str_contains($r, 'en attente planification')
            || str_contains($r, 'reprogramm')
            || str_contains($r, 'planifi')
        ) {
            return 'attente_planification';
        }

        if (str_contains($r, 'clotur') || str_contains($r, 'closed') || str_contains($r, 'ferme')) {
            return 'clôturé';
        }

        if (str_contains($r, 'valid')) {
            return 'validé';
        }

        if (str_contains($r, 'traite') || str_contains($r, 'resolu') || str_contains($r, 'resolved')) {
            return 'traité';
        }

        if (
            str_contains($r, 'en cours')
            || str_contains($r, 'encours')
            || str_contains($r, 'progress')
            || str_contains($r, 'processing')
            || str_contains($r, 'derangement')
        ) {
            return 'en_cours';
        }

        if (str_contains($r, 'assign') || str_contains($r, 'attribu') || str_contains($r, 'dispatch')) {
            return 'assigné';
        }

        return 'nouveau';
    }

    protected function normalizePriority(string $raw): string
    {
        $map = [
            'haute' => 'Haute', 'high' => 'Haute', 'urgent' => 'Urgent',
            'critique' => 'Urgent', 'critical' => 'Urgent',
            'moyenne' => 'Moyenne', 'medium' => 'Moyenne', 'normal' => 'Moyenne',
            'basse' => 'Basse', 'low' => 'Basse', 'faible' => 'Basse',
        ];
        return $map[strtolower(trim($raw))] ?? 'Moyenne';
    }

    protected function parseGpsCoordinates(string $gpsRaw): array
    {
        if ($gpsRaw !== '' && preg_match('/(-?\d+(?:[\.,]\d+)?)\s*[,;\/ ]\s*(-?\d+(?:[\.,]\d+)?)/', $gpsRaw, $m)) {
            return [
                str_replace(',', '.', $m[1]),
                str_replace(',', '.', $m[2]),
            ];
        }

        return [null, null];
    }

    protected function hasValidCoordinates($lat, $lng): bool
    {
        if ($lat === null || $lng === null || $lat === '' || $lng === '') {
            return false;
        }

        $latitude = (float)str_replace(',', '.', (string)$lat);
        $longitude = (float)str_replace(',', '.', (string)$lng);

        return $latitude >= -90 && $latitude <= 90 && $longitude >= -180 && $longitude <= 180;
    }

    protected function findImportedFieldValue(array $extraFields, array $labels): ?string
    {
        $normalizedCandidates = array_map([$this, 'normalizeImportHeader'], $labels);

        foreach ($extraFields as $label => $value) {
            $normalizedLabel = $this->normalizeImportHeader((string)$label);
            if ($normalizedLabel === '') {
                continue;
            }

            foreach ($normalizedCandidates as $candidate) {
                if ($candidate !== '' && ($normalizedLabel === $candidate || str_contains($normalizedLabel, $candidate) || str_contains($candidate, $normalizedLabel))) {
                    $fieldValue = trim((string)$value);
                    return $fieldValue !== '' ? $fieldValue : null;
                }
            }
        }

        return null;
    }

    protected function resolveTicketClientCoordinates(array $ticket): array
    {
        $structuredLat = $ticket['client_lat'] ?? null;
        $structuredLng = $ticket['client_lng'] ?? null;
        if ($this->hasValidCoordinates($structuredLat, $structuredLng)) {
            return [
                'lat' => (float)str_replace(',', '.', (string)$structuredLat),
                'lng' => (float)str_replace(',', '.', (string)$structuredLng),
                'source' => 'ticket',
            ];
        }

        $extraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode((string)$ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $extraFields = $decoded;
            }
        }

        $importedGps = $this->findImportedFieldValue($extraFields, ['GPS', 'Coordonnées GPS', 'Coordonnees GPS']);
        [$parsedLat, $parsedLng] = $this->parseGpsCoordinates((string)($importedGps ?? ''));
        if ($this->hasValidCoordinates($parsedLat, $parsedLng)) {
            return [
                'lat' => (float)$parsedLat,
                'lng' => (float)$parsedLng,
                'source' => 'import',
            ];
        }

        $importedLat = $this->findImportedFieldValue($extraFields, ['Latitude_Terrai', 'Latitude_Terrain', 'ck_latitude', 'latitude']);
        $importedLng = $this->findImportedFieldValue($extraFields, ['Longitude_Terrain', 'Longitude_Terrai', 'ck_longitude', 'longitude']);
        if ($this->hasValidCoordinates($importedLat, $importedLng)) {
            return [
                'lat' => (float)str_replace(',', '.', (string)$importedLat),
                'lng' => (float)str_replace(',', '.', (string)$importedLng),
                'source' => 'import_columns',
            ];
        }

        return [
            'lat' => null,
            'lng' => null,
            'source' => null,
        ];
    }

    private function resolveRaccordementStudyCoordinates(?array $report, array $ticket): array
    {
        $studyLat = $report['study_gps_lat'] ?? null;
        $studyLng = $report['study_gps_lng'] ?? null;
        if ($this->hasValidCoordinates($studyLat, $studyLng)) {
            return [
                'lat' => (float)str_replace(',', '.', (string)$studyLat),
                'lng' => (float)str_replace(',', '.', (string)$studyLng),
                'source' => 'study',
            ];
        }

        return $this->resolveTicketClientCoordinates($ticket);
    }

    private function getExpectedImportHeaders(bool $isRaccordementClients = false): array
    {
        if ($isRaccordementClients) {
            return [
                'Ville',
                'Commune',
                'NumeroAbonnement',
                'NomClient',
                'Localisation',
                'Contact1',
                'Contact2',
                'Latitude_Terrai',
                'Longitude_Terrain',
                'PLAQUE',
                'JDV',
                'PCO',
                'SNONT',
                'ND',
                'Date_Enregistrement_Instances',
                'Duree_attente_j',
                'Date_Envoi_Instances_Prestataires',
                'EntrepriseFTTH',
                'Etude_Technique',
                'Commentaires',
                'Problèmes',
                'Equipe_raccordement',
            ];
        }

        return [
            'Numéro de Requête',
            'Ligne fixe',
            'Numéro Abonnement',
            'Nom du client',
            'CONTACT CLIENT 1',
            'CONTACT CLIENT 2',
            'LOCALISATION',
            'GPS',
            'SRO CLIENT',
            'JDV CLIENT',
            'PCO CLIENT',
            'Entreprise/porteur d\'action',
            'equipes',
            'Avancement',
            'Statut',
            'Commentaire',
            'Commentaire_cause',
            'Date de Clôture',
        ];
    }

    private function getExpectedImportFileLabel(bool $isRaccordementClients = false): string
    {
        return $isRaccordementClients
            ? 'Instanceprestataire_template_wandoo.xlsx (onglet RACCORDES)'
            : 'LE STATUS DES DIFFERENTS CAS DES CLIENTS.xlsx';
    }

    protected function fetchAssignableTechnicians(string $forcedType = ''): array
    {
        $sql = "SELECT id, name, email
                FROM users
                WHERE active = 1
                  AND role_key IN ('technicien','technician')";
        $params = [];

        if ($forcedType !== '') {
            $assignableType = $forcedType;
        } else {
            $assignableType = $this->isRaccordementClientsRequest() ? 'etude' : 'maintenance_ftth';
        }
        if ($assignableType !== '') {
            $sql .= ' AND technician_type = ?';
            $params[] = $assignableType;
        }

        $sql .= ' ORDER BY name';
        $statement = $this->pdo->prepare($sql);
        $statement->execute($params);

        return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }

    protected function filterAssignableTechnicianIds(array $techIds): array
    {
        $techIds = array_values(array_unique(array_filter(array_map('intval', $techIds), static fn(int $id): bool => $id > 0)));
        if ($techIds === []) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($techIds), '?'));
        $sql = "SELECT id
                FROM users
                WHERE active = 1
                  AND role_key IN ('technicien','technician')
                  AND id IN ($placeholders)";
        $params = $techIds;

        $assignableType = $this->isRaccordementClientsRequest() ? 'etude' : 'maintenance_ftth';
        if ($assignableType !== '') {
            $sql .= ' AND technician_type = ?';
            $params[] = $assignableType;
        }

        $statement = $this->pdo->prepare($sql);
        $statement->execute($params);

        return array_map('intval', $statement->fetchAll(PDO::FETCH_COLUMN) ?: []);
    }

    protected function assignTechniciansToTicket(int $ticketId, array $techIds, int $assignedBy, string $notes = ''): array
    {
        $moduleBasePath = $this->currentModuleBasePath();
        $ins = $this->pdo->prepare(
            'INSERT INTO maintenance_ftth_assignments (ticket_id, technician_id, assigned_by, notes)
             VALUES (?,?,?,?)'
        );
        $exists = $this->pdo->prepare(
            'SELECT 1 FROM maintenance_ftth_assignments WHERE ticket_id=? AND technician_id=? LIMIT 1'
        );

        $assignedIds = [];
        foreach (array_unique(array_filter($techIds)) as $tid) {
            $exists->execute([$ticketId, $tid]);
            if ($exists->fetchColumn()) {
                continue;
            }

            try {
                $ins->execute([$ticketId, $tid, $assignedBy, $notes]);
                $assignedIds[] = (int)$tid;
            } catch (\Throwable $e) {
                // duplicate: ignorer
            }
        }

        if (empty($assignedIds)) {
            return [];
        }

        $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
            ->execute(['assigné', $ticketId]);

        $tkSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=?');
        $tkSt->execute([$ticketId]);
        $ticket = $tkSt->fetch(PDO::FETCH_ASSOC);

        $title = '[FTTH] Nouvelle intervention assignée';
        $body  = 'Vous avez été assigné à l\'intervention FTTH pour le client ' . ($ticket['client_name'] ?? '') . '.';
        if ($ticket['nature_intervention'] ?? '') {
            $body .= ' Nature : ' . $ticket['nature_intervention'] . '.';
        }
        if ($this->ticketMatchesModule($ticket ?: [], true)) {
            $title = '[Raccordement] Nouvelle intervention assignée';
            $body  = 'Vous avez été assigné au dossier de raccordement pour le client ' . ($ticket['client_name'] ?? '') . '.';
            if ($ticket['nature_intervention'] ?? '') {
                $body .= ' Nature : ' . $ticket['nature_intervention'] . '.';
            }
        }
        $url = $this->detailPathForModule($ticketId, $moduleBasePath);

        Notifier::notifyUsers($this->pdo, $assignedIds, $title, $body, $url);
        Notifier::emailUsers($this->pdo, $assignedIds, $title, nl2br(htmlspecialchars($body)));
        Notifier::pushUsers($this->pdo, $assignedIds, $title, $body, $url);

        try {
            $publicClientSt = $this->pdo->prepare('SELECT public_client_id FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
            $publicClientSt->execute([$ticketId]);
            $publicClientId = (int)($publicClientSt->fetchColumn() ?: 0);
            if ($publicClientId > 0) {
                $clientTitle = '[e-Intervention] Demande prise en charge';
                $clientBody = 'Votre demande a été prise en charge par nos équipes et transmise au terrain pour intervention.';
                Notifier::notifyPublicClients($this->pdo, [$publicClientId], $clientTitle, $clientBody, '#/history');
                $this->pdo->prepare("UPDATE public_mobile_incidents SET status='assigne_technicien', updated_at=NOW() WHERE ticket_id=? AND status IN ('soumis','prise_en_charge','assigne_technicien')")
                    ->execute([$ticketId]);
            }
        } catch (\Throwable $e) {
            // Ne pas bloquer l'assignation FTTH si la couche publique n'est pas encore initialisée.
        }

        return $assignedIds;
    }

    protected function removeAssignmentsFromTicket(int $ticketId, ?int $technicianId = null): int
    {
        if ($ticketId <= 0) {
            return 0;
        }

        if ($this->isAssignmentLocked($ticketId)) {
            return 0;
        }

        if ($technicianId !== null && $technicianId > 0) {
            $deleteSt = $this->pdo->prepare('DELETE FROM maintenance_ftth_assignments WHERE ticket_id=? AND technician_id=?');
            $deleteSt->execute([$ticketId, $technicianId]);
            $deletedCount = $deleteSt->rowCount();
        } else {
            $deleteSt = $this->pdo->prepare('DELETE FROM maintenance_ftth_assignments WHERE ticket_id=?');
            $deleteSt->execute([$ticketId]);
            $deletedCount = $deleteSt->rowCount();
        }

        $remainingSt = $this->pdo->prepare('SELECT COUNT(*) FROM maintenance_ftth_assignments WHERE ticket_id=?');
        $remainingSt->execute([$ticketId]);
        $remainingCount = (int)$remainingSt->fetchColumn();

        if ($remainingCount === 0) {
            $statusSt = $this->pdo->prepare('SELECT status FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
            $statusSt->execute([$ticketId]);
            $currentStatus = (string)($statusSt->fetchColumn() ?: '');

            if ($currentStatus === 'assigné') {
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
                    ->execute(['nouveau', $ticketId]);
            }
        }

        return $deletedCount;
    }

    private function syncAssignmentStatusConsistency(?int $ticketId = null): void
    {
        try {
            if ($ticketId !== null && $ticketId > 0) {
                $statement = $this->pdo->prepare(
                    "UPDATE maintenance_ftth_tickets t
                     SET t.status = 'nouveau'
                     WHERE t.id = ?
                       AND t.status = 'assigné'
                       AND NOT EXISTS (
                           SELECT 1
                           FROM maintenance_ftth_assignments a
                           WHERE a.ticket_id = t.id
                       )"
                );
                $statement->execute([$ticketId]);
                return;
            }

            $this->pdo->exec(
                "UPDATE maintenance_ftth_tickets t
                 SET t.status = 'nouveau'
                 WHERE t.status = 'assigné'
                   AND NOT EXISTS (
                       SELECT 1
                       FROM maintenance_ftth_assignments a
                       WHERE a.ticket_id = t.id
                   )"
            );
        } catch (\Throwable $e) {
            // Ne pas bloquer l'UI en cas d'échec de synchronisation.
        }
    }

    private function isAssignmentLocked(int $ticketId): bool
    {
        if ($ticketId <= 0) {
            return false;
        }

        try {
            $st = $this->pdo->prepare('SELECT status FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
            $st->execute([$ticketId]);
            $status = (string)($st->fetchColumn() ?: '');
            return in_array($status, ['validé', 'clôturé'], true);
        } catch (\Throwable $e) {
            return false;
        }
    }

    private function parseSelectedTicketIds($rawIds): array
    {
        if (is_string($rawIds)) {
            $rawIds = preg_split('/[\s,;]+/', $rawIds) ?: [];
        }

        if (!is_array($rawIds)) {
            $rawIds = [$rawIds];
        }

        $ids = array_values(array_unique(array_filter(array_map('intval', $rawIds), static fn(int $id): bool => $id > 0)));
        return array_slice($ids, 0, 200);
    }

    private function removeDirectoryRecursive(string $dirPath): void
    {
        if (!is_dir($dirPath)) {
            return;
        }

        $items = scandir($dirPath);
        if ($items === false) {
            return;
        }

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            $fullPath = $dirPath . DIRECTORY_SEPARATOR . $item;
            if (is_dir($fullPath)) {
                $this->removeDirectoryRecursive($fullPath);
                continue;
            }

            @unlink($fullPath);
        }

        @rmdir($dirPath);
    }

    private function deleteTicketsByIds(array $ticketIds): int
    {
        $ticketIds = $this->parseSelectedTicketIds($ticketIds);
        if (empty($ticketIds)) {
            return 0;
        }

        $placeholders = implode(',', array_fill(0, count($ticketIds), '?'));

        $existingSt = $this->pdo->prepare("SELECT id FROM maintenance_ftth_tickets WHERE id IN ($placeholders)");
        $existingSt->execute($ticketIds);
        $existingIds = array_map('intval', $existingSt->fetchAll(PDO::FETCH_COLUMN) ?: []);

        if (empty($existingIds)) {
            return 0;
        }

        $ticketPlaceholders = implode(',', array_fill(0, count($existingIds), '?'));

        $reportSt = $this->pdo->prepare("SELECT id FROM maintenance_ftth_reports WHERE ticket_id IN ($ticketPlaceholders)");
        $reportSt->execute($existingIds);
        $reportIds = array_map('intval', $reportSt->fetchAll(PDO::FETCH_COLUMN) ?: []);

        $this->pdo->beginTransaction();
        try {
            if (!empty($reportIds)) {
                $reportPlaceholders = implode(',', array_fill(0, count($reportIds), '?'));
                $this->pdo->prepare("DELETE FROM maintenance_ftth_photos WHERE report_id IN ($reportPlaceholders)")
                    ->execute($reportIds);
            }

            $this->pdo->prepare("DELETE FROM maintenance_ftth_reports WHERE ticket_id IN ($ticketPlaceholders)")
                ->execute($existingIds);
            $this->pdo->prepare("DELETE FROM maintenance_ftth_assignments WHERE ticket_id IN ($ticketPlaceholders)")
                ->execute($existingIds);
            $this->pdo->prepare("DELETE FROM maintenance_ftth_tickets WHERE id IN ($ticketPlaceholders)")
                ->execute($existingIds);

            $this->pdo->commit();
        } catch (\Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }

        foreach ($existingIds as $ticketId) {
            $this->removeDirectoryRecursive(__DIR__ . '/../../public/storage/uploads/ftth/' . $ticketId);
        }

        return count($existingIds);
    }

    protected function logTicketEvent(int $ticketId, ?int $actorId, string $eventType, string $title, ?string $detail = null, string $tone = 'secondary', string $icon = 'clock-rotate-left'): void
    {
        if ($ticketId <= 0 || trim($eventType) === '' || trim($title) === '') {
            return;
        }

        try {
            $st = $this->pdo->prepare(
                'INSERT INTO maintenance_ftth_ticket_events (ticket_id, actor_id, event_type, title, detail, tone, icon)
                 VALUES (?,?,?,?,?,?,?)'
            );
            $st->execute([
                $ticketId,
                $actorId,
                trim($eventType),
                trim($title),
                $detail !== null && trim($detail) !== '' ? trim($detail) : null,
                trim($tone) !== '' ? trim($tone) : 'secondary',
                trim($icon) !== '' ? trim($icon) : 'clock-rotate-left',
            ]);
        } catch (\Throwable $e) {
            // Ne pas bloquer le flux principal si le journal d'audit échoue.
        }
    }

    protected function fetchTicketEvents(int $ticketId): array
    {
        if ($ticketId <= 0) {
            return [];
        }

        try {
            $st = $this->pdo->prepare(
                'SELECT e.*, u.name AS actor_name
                 FROM maintenance_ftth_ticket_events e
                 LEFT JOIN users u ON u.id = e.actor_id
                 WHERE e.ticket_id=?
                 ORDER BY e.created_at DESC, e.id DESC'
            );
            $st->execute([$ticketId]);
            return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        } catch (\Throwable $e) {
            return [];
        }
    }

    private function fetchTicketsForSelectionReport(array $ticketIds): array
    {
        $ticketIds = $this->parseSelectedTicketIds($ticketIds);
        if (empty($ticketIds)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($ticketIds), '?'));
        $sql =
            "SELECT t.*,
                    u.name AS imported_by_name,
                    r.id AS report_id,
                    r.status AS report_status,
                ut.name AS technician_name,
                    r.operation_type,
                    r.intervention_date,
                    r.intervention_start,
                    r.intervention_end,
                r.impacted_equipment,
                r.materials_used,
                    r.general_comments,
                    r.constat_comments,
                r.satisfaction_service,
                r.satisfaction_delay,
                r.satisfaction_quality,
                r.satisfaction_behavior,
                r.client_name_onsite,
                    r.client_remarks,
                    r.service_restored,
                r.client_signature_path,
                r.agent_signature_path,
                r.submit_gps_lat,
                r.submit_gps_lng,
                    r.submitted_at,
                    r.validated_at,
                    r.updated_at AS report_updated_at,
                    (SELECT GROUP_CONCAT(us.name SEPARATOR ', ')
                     FROM maintenance_ftth_assignments a
                     JOIN users us ON us.id = a.technician_id
                     WHERE a.ticket_id = t.id) AS assigned_to
             FROM maintenance_ftth_tickets t
             LEFT JOIN users u ON u.id = t.imported_by
             LEFT JOIN maintenance_ftth_reports r ON r.ticket_id = t.id
             LEFT JOIN users ut ON ut.id = r.technician_id
             WHERE t.id IN ($placeholders)
             ORDER BY t.created_at DESC";

        $st = $this->pdo->prepare($sql);
        $st->execute($ticketIds);
        $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];

        $sortOrder = array_flip($ticketIds);
        usort($rows, static function (array $left, array $right) use ($sortOrder): int {
            return ($sortOrder[(int)$left['id']] ?? PHP_INT_MAX) <=> ($sortOrder[(int)$right['id']] ?? PHP_INT_MAX);
        });

        $reportIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'report_id')))));
        $photosByReport = [];
        if (!empty($reportIds)) {
            $photoPlaceholders = implode(',', array_fill(0, count($reportIds), '?'));
            $photoSt = $this->pdo->prepare(
                "SELECT * FROM maintenance_ftth_photos WHERE report_id IN ($photoPlaceholders) ORDER BY tab_type, id"
            );
            $photoSt->execute($reportIds);
            foreach ($photoSt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $photo) {
                $photosByReport[(int)$photo['report_id']][] = $photo;
            }
        }

        foreach ($rows as &$row) {
            $reportPhotos = $photosByReport[(int)($row['report_id'] ?? 0)] ?? [];
            $row['impacted_equipment'] = json_decode((string)($row['impacted_equipment'] ?? '[]'), true) ?: [];
            $row['materials_used'] = json_decode((string)($row['materials_used'] ?? '[]'), true) ?: [];
            $row['constat_photos'] = array_values(array_filter($reportPhotos, static fn(array $photo): bool => ($photo['tab_type'] ?? '') === 'constat'));
            $row['intervention_photos'] = array_values(array_filter($reportPhotos, static fn(array $photo): bool => ($photo['tab_type'] ?? '') === 'intervention'));
        }
        unset($row);

        return $rows;
    }

    private function fetchDistinctTicketColumnValues(string $column, bool $isRaccordementClients = false): array
    {
        $allowedColumns = ['sro_client', 'company_name', 'avancement'];
        if (!in_array($column, $allowedColumns, true)) {
            return [];
        }

        $moduleClause = $this->maintenanceModuleWhereClause($isRaccordementClients);
        $st = $this->pdo->query(
            "SELECT DISTINCT {$column} AS value
             FROM maintenance_ftth_tickets
             WHERE {$column} IS NOT NULL AND TRIM({$column}) <> '' AND {$moduleClause}
             ORDER BY {$column} ASC"
        );

        return array_values(array_filter(array_map(
            static fn($value) => trim((string)$value),
            $st->fetchAll(PDO::FETCH_COLUMN) ?: []
        )));
    }

    protected function isRaccordementClientsRequest(): bool
    {
        return false;
    }

    private function maintenanceModuleWhereClause(bool $isRaccordementClients, string $alias = ''): string
    {
        $prefix = $alias !== '' ? $alias . '.' : '';
        $raccordementClause = '(
            LOWER(COALESCE(' . $prefix . 'nature_intervention, "")) LIKE "%raccordement%"
            OR LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(' . $prefix . 'extra_fields, "$.module")), "")) = "raccordement-clients"
        )';

        return $isRaccordementClients ? $raccordementClause : 'NOT ' . $raccordementClause;
    }

    private function ticketMatchesModule(array $ticket, bool $isRaccordementClients): bool
    {
        $extraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode((string)$ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $extraFields = $decoded;
            }
        }

        $isRaccordementTicket = false;
        $nature = mb_strtolower(trim((string)($ticket['nature_intervention'] ?? '')));
        if ($nature !== '' && str_contains($nature, 'raccordement')) {
            $isRaccordementTicket = true;
        }

        if (!$isRaccordementTicket && array_key_exists('module', $extraFields)) {
            $value = mb_strtolower(trim((string)$extraFields['module']));
            if ($value === 'raccordement-clients') {
                $isRaccordementTicket = true;
            }
        }

        return $isRaccordementClients === $isRaccordementTicket;
    }

    private function ticketUsesRaccordementMobileUi(array $ticket): bool
    {
        $nature = mb_strtolower(trim((string)($ticket['nature_intervention'] ?? '')));
        if ($nature !== '' && str_contains($nature, 'raccordement')) {
            return true;
        }

        if (empty($ticket['extra_fields'])) {
            return false;
        }

        $decoded = json_decode((string)$ticket['extra_fields'], true);
        if (!is_array($decoded) || !array_key_exists('module', $decoded)) {
            return false;
        }

        $module = mb_strtolower(trim((string)$decoded['module']));
        $normalizedModule = str_replace(['_', '-'], ' ', $module);

        return in_array($normalizedModule, ['raccordement clients', 'raccordement client'], true);
    }

    protected function detectUserPhotoColumn(): ?string
    {
        static $cached = false;
        static $column = null;

        if ($cached) {
            return $column;
        }

        $cached = true;

        try {
            $st = $this->pdo->query('SHOW COLUMNS FROM users');
            $available = array_map(
                fn(array $row): string => strtolower((string)($row['Field'] ?? '')),
                $st->fetchAll(PDO::FETCH_ASSOC)
            );
        } catch (\Throwable $e) {
            return null;
        }

        foreach (['photo_path', 'avatar_path', 'profile_photo', 'profile_picture', 'avatar', 'photo', 'image_path'] as $candidate) {
            if (in_array(strtolower($candidate), $available, true)) {
                $column = $candidate;
                break;
            }
        }

        return $column;
    }

    protected function resolveUserPhotoUrl(?string $rawPath): ?string
    {
        $path = trim((string)$rawPath);
        if ($path === '') {
            return null;
        }

        if (preg_match('~^https?://~i', $path)) {
            return $path;
        }

        $normalized = ltrim(str_replace('\\', '/', $path), '/');
        if ($normalized === '') {
            return null;
        }

        if (str_starts_with($normalized, 'public/')) {
            $normalized = substr($normalized, 7);
        }

        if (str_starts_with($normalized, 'storage/')) {
            return base_url($normalized);
        }

        if (str_starts_with($normalized, 'uploads/')) {
            return base_url('storage/' . $normalized);
        }

        return upload_url($normalized);
    }

    protected function buildInitials(string $name): string
    {
        $parts = preg_split('/\s+/', trim($name)) ?: [];
        $initials = '';

        foreach ($parts as $part) {
            if ($part === '') {
                continue;
            }
            $initials .= strtoupper(function_exists('mb_substr') ? mb_substr($part, 0, 1, 'UTF-8') : substr($part, 0, 1));
            if (strlen($initials) >= 2) {
                break;
            }
        }

        return $initials !== '' ? $initials : 'FT';
    }

    // ================================================================
    // WEB — SHOW (détail + assignation)
    // ================================================================
    public function show(): void
    {
        $id = (int)($_GET['id'] ?? 0);
        $u = $this->requireAuth();
        $isRaccordementClients = $this->isRaccordementClientsRequest();

        if (session_status() === PHP_SESSION_ACTIVE) {
            $_SESSION['last_maintenance_ticket_id'] = $id;
        }

        $this->syncAssignmentStatusConsistency($id);

        if ($id <= 0) {
            $this->redirect($this->currentModuleBasePath());
            return;
        }

        $viewScope = $this->resolveMaintenanceScope('view', $u);
        if ($viewScope === 'none') {
            http_response_code(403);
            exit('Accès refusé.');
        }

        if (!$this->canAccessMaintenanceTicket($id, $u, 'view')) {
            http_response_code(403);
            exit('Accès refusé.');
        }

        $st = $this->pdo->prepare(
            'SELECT t.*, u.name AS imported_by_name
             FROM maintenance_ftth_tickets t
             LEFT JOIN users u ON u.id = t.imported_by
             WHERE t.id = ? LIMIT 1'
        );
        $st->execute([$id]);
        $ticket = $st->fetch(PDO::FETCH_ASSOC);

        if (!$ticket) {
            http_response_code(404);
            exit('Ticket non trouvé.');
        }

        if (!$this->ticketMatchesModule($ticket, $isRaccordementClients)) {
            http_response_code(404);
            exit('Ticket non trouvé pour ce module.');
        }

        // Assignations existantes
        $photoColumn = $this->detectUserPhotoColumn();
        $selectPhoto = $photoColumn ? ', u.`' . str_replace('`', '', $photoColumn) . '` AS tech_photo_path' : ', NULL AS tech_photo_path';
        $assignSt = $this->pdo->prepare(
            'SELECT a.*, u.name AS tech_name, u.email AS tech_email, u.phone AS tech_phone' . $selectPhoto . '
             FROM maintenance_ftth_assignments a
             JOIN users u ON u.id = a.technician_id
             WHERE a.ticket_id = ?
             ORDER BY a.assigned_at DESC'
        );
        $assignSt->execute([$id]);
        $assignments = array_map(function (array $assignment): array {
            $assignment['tech_avatar_url'] = $this->resolveUserPhotoUrl($assignment['tech_photo_path'] ?? null);
            $assignment['tech_initials'] = $this->buildInitials((string)($assignment['tech_name'] ?? ''));
            return $assignment;
        }, $assignSt->fetchAll(PDO::FETCH_ASSOC));

        // Cohérence d'affichage: éviter "assigné" si aucun technicien n'est réellement rattaché.
        if (($ticket['status'] ?? '') === 'assigné' && empty($assignments)) {
            $ticket['status'] = 'nouveau';
        }

        // Rapport terrain
        $repSt = $this->pdo->prepare(
            'SELECT r.*, r.id AS report_id,
                    uv.name AS validated_by_name,
                    ut.name AS technician_name
             FROM maintenance_ftth_reports r
             LEFT JOIN users uv ON uv.id = r.validated_by
             LEFT JOIN users ut ON ut.id = r.technician_id
             WHERE r.ticket_id = ? AND r.id > 0
             ORDER BY COALESCE(r.updated_at, r.created_at) DESC, r.id DESC
             LIMIT 1'
        );
        $repSt->execute([$id]);
        $report = $repSt->fetch(PDO::FETCH_ASSOC) ?: null;

        // Photos du rapport
        $photos = [];
        if ($report) {
            $phSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_photos WHERE report_id=? ORDER BY tab_type, id');
            $phSt->execute([(int)($report['report_id'] ?? $report['id'] ?? 0)]);
            $photos = $phSt->fetchAll(PDO::FETCH_ASSOC);
        }

        // Liste des techniciens disponibles pour l'assignation
        // Pour raccordement clients : filtre par phase (etude → type etude, installation → type raccordement)
        $assignableRaccordementType = '';
        if ($isRaccordementClients) {
            $currentPhaseForAssign = (string)($report['workflow_phase'] ?? 'study');
            $assignableRaccordementType = $currentPhaseForAssign !== 'study'
                ? 'raccordement'
                : 'etude';
        }
        $technicians = $this->fetchAssignableTechnicians($assignableRaccordementType);

        // Décoder extra_fields (toutes les colonnes Excel brutes)
        $extraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode($ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $extraFields = $decoded;
            }
        }

        $raccordementWorkflow = null;
        if ($isRaccordementClients) {
            $ticketCoordinates = $this->resolveTicketClientCoordinates($ticket);
            $recommendationCoordinates = $this->resolveRaccordementStudyCoordinates($report, $ticket);
            $zoneSetting = RaccordementOtbService::getZoneSetting($this->pdo, (string)($ticket['dispatch_zone_key'] ?? ''));
            $zoneTeamIds = $this->resolveRaccordementZoneTeamTechnicianIds($ticket);
            $otbRecommendation = RaccordementOtbService::buildRecommendation(
                $this->pdo,
                $recommendationCoordinates['lat'],
                $recommendationCoordinates['lng'],
                (string)($ticket['dispatch_zone_key'] ?? '')
            );
            $selectedBoxId = (int)($report['selected_otb_id'] ?? 0);
            $selectedBox = $selectedBoxId > 0 ? RaccordementOtbService::findBoxById($this->pdo, $selectedBoxId) : null;
            $portOptions = $selectedBoxId > 0
                ? RaccordementOtbService::getAvailablePorts($this->pdo, $selectedBoxId, (int)($report['id'] ?? 0) ?: null)
                : [];
            $portsByBox = [];
            foreach (array_merge($selectedBox ? [$selectedBox] : [], $otbRecommendation['candidates'] ?? [], $otbRecommendation['out_of_radius'] ?? []) as $box) {
                $boxId = (int)($box['id'] ?? 0);
                if ($boxId <= 0 || isset($portsByBox[$boxId])) {
                    continue;
                }
                $portsByBox[$boxId] = RaccordementOtbService::getAvailablePorts($this->pdo, $boxId, (int)($report['id'] ?? 0) ?: null);
            }

            $raccordementWorkflow = [
                'phase' => (string)($report['workflow_phase'] ?? 'study'),
                'assignable_technician_type' => $assignableRaccordementType !== '' ? $assignableRaccordementType : 'etude',
                'zone_setting' => $zoneSetting,
                'zone_team_ids' => $zoneTeamIds,
                'handoff_technicians' => $this->resolveRaccordementHandoffTechnicians(
                    $ticket,
                    $id,
                    (int)($report['study_technician_id'] ?? ($u['id'] ?? 0))
                ),
                'otb' => $otbRecommendation,
                'selected_box' => $selectedBox,
                'port_options' => $portOptions,
                'ports_by_box' => $portsByBox,
                'study_technician' => $this->findUserById((int)($report['study_technician_id'] ?? 0)),
                'installation_technician' => $this->findUserById((int)($report['installation_technician_id'] ?? 0)),
                'study_can_edit' => $this->canEditRaccordementStudyPhase($u, $report),
                'installation_can_edit' => $this->canEditRaccordementInstallationPhase($u, $report),
                'ticket_coordinates' => $ticketCoordinates,
                'recommendation_coordinates' => $recommendationCoordinates,
            ];
        }

        $ticketEvents = $this->fetchTicketEvents($id);
        $detailView = $isRaccordementClients ? 'raccordement_clients/show' : 'maintenance_ftth/show';

        $this->view($detailView, [
            'title'       => ($isRaccordementClients ? 'Raccordement' : 'FTTH') . ' — ' . ($ticket['ref_code'] ?: 'Ticket #' . $ticket['id']),
            'isRaccordementClients' => $isRaccordementClients,
            'moduleBasePath' => $this->currentModuleBasePath(),
            'ticket'      => $ticket,
            'extraFields' => $extraFields,
            'assignments' => $assignments,
            'report'      => $report,
            'photos'      => $photos,
            'ticketEvents'=> $ticketEvents,
            'technicians' => $technicians,
            'raccordementWorkflow' => $raccordementWorkflow,
            'user'        => $u,
        ]);
    }

    // ================================================================
    // WEB — ASSIGN (dispatching)
    // ================================================================
    public function assign(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();
        $isRaccordementClients = $this->isRaccordementClientsRequest();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $ticketId  = (int)($_POST['ticket_id'] ?? 0);
        $techIds   = array_values(array_unique(array_filter(array_map('intval', (array)($_POST['technician_ids'] ?? [])))));
        $notes     = trim($_POST['notes'] ?? '');

        if ($ticketId <= 0 || empty($techIds)) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=missing');
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'assign')) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=forbidden');
            return;
        }

        if ($isRaccordementClients) {
            $techIds = [reset($techIds) ?: 0];
            $techIds = array_values(array_filter(array_map('intval', $techIds)));
            if (empty($techIds)) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Sélectionnez un technicien.'));
                return;
            }
        }

        if ($isRaccordementClients) {
            $assignedTechId = (int)$techIds[0];
            $reportStmt = $this->pdo->prepare('SELECT id, workflow_phase FROM maintenance_ftth_reports WHERE ticket_id = ? LIMIT 1');
            $reportStmt->execute([$ticketId]);
            $reportRow = $reportStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
            $reportId = (int)($reportRow['id'] ?? 0);
            $reportPhase = (string)($reportRow['workflow_phase'] ?? 'study');

            if ($reportPhase === 'installation') {
                $this->removeAssignmentsFromTicket($ticketId, null);
            }

            $this->assignTechniciansToTicket($ticketId, $techIds, (int)$u['id'], $notes);

            if ($reportId > 0) {
                if ($reportPhase === 'installation') {
                    // Phase installation : on assigne le technicien raccordement
                    $this->pdo->prepare('UPDATE maintenance_ftth_reports SET installation_technician_id = ? WHERE id = ?')
                        ->execute([$assignedTechId, $reportId]);

                    // Notifier le technicien raccordement
                    $tkSt = $this->pdo->prepare('SELECT client_name, ref_code FROM maintenance_ftth_tickets WHERE id = ?');
                    $tkSt->execute([$ticketId]);
                    $tkRow   = $tkSt->fetch(\PDO::FETCH_ASSOC) ?: [];
                    $cName   = trim((string)($tkRow['client_name'] ?? 'client'));
                    $refCode = trim((string)($tkRow['ref_code'] ?? ('Ticket #' . $ticketId)));
                    Notifier::notifyUsers($this->pdo, [$assignedTechId],
                        '[Raccordement] Dossier installation assigné',
                        $u['name'] . ' vous a assigné le dossier installation de ' . $cName . ' (' . $refCode . ').',
                        '/raccordement-clients/' . $ticketId
                    );
                    Notifier::pushUsers($this->pdo, [$assignedTechId],
                        '[Raccordement] Dossier assigné',
                        'Un dossier raccordement installation vous a été assigné.',
                        '/raccordement-clients/' . $ticketId
                    );
                } else {
                    // Phase étude : comportement existant
                    $this->pdo->prepare('UPDATE maintenance_ftth_reports SET study_technician_id = COALESCE(study_technician_id, ?), workflow_phase = COALESCE(workflow_phase, "study") WHERE id = ?')
                        ->execute([$assignedTechId, $reportId]);
                }
            }
        } else {
            $this->assignTechniciansToTicket($ticketId, $techIds, (int)$u['id'], $notes);
        }

        $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&assigned=1');
    }

    public function bulkAssign(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $ticketIds = $this->parseSelectedTicketIds($_POST['selected_ticket_ids'] ?? []);
        $techIds = $this->filterAssignableTechnicianIds((array)($_POST['technician_ids'] ?? []));
        $notes = trim((string)($_POST['notes'] ?? ''));

        if (empty($ticketIds) || empty($techIds)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Sélectionnez au moins un ticket et un technicien pour l’assignation rapide.'));
            return;
        }

        $ticketIds = $this->filterAccessibleMaintenanceTicketIds($ticketIds, $u, $this->resolveMaintenanceScope('assign', $u));
        if (empty($ticketIds)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Aucun ticket accessible pour l’assignation rapide dans votre ville de rattachement.'));
            return;
        }

        $assignedTicketCount = 0;
        foreach ($ticketIds as $ticketId) {
            if (!empty($this->assignTechniciansToTicket($ticketId, $techIds, (int)$u['id'], $notes))) {
                $assignedTicketCount++;
            }
        }

        $this->redirect($moduleBasePath . '?bulk_assigned=' . $assignedTicketCount . '&selection=' . count($ticketIds));
    }

    public function unassign(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        $technicianId = (int)($_POST['technician_id'] ?? 0);
        $redirectTo = trim((string)($_POST['redirect_to'] ?? 'list'));

        if ($ticketId <= 0) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Ticket invalide pour la désassignation.'));
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'assign')) {
            if ($redirectTo === 'detail') {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=forbidden');
                return;
            }
            $this->redirect($moduleBasePath . '?err=forbidden');
            return;
        }

        if ($this->isAssignmentLocked($ticketId)) {
            $message = urlencode('La désassignation est impossible sur un ticket validé ou clôturé.');
            if ($redirectTo === 'detail') {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . $message);
                return;
            }

            $this->redirect($moduleBasePath . '?err=' . $message);
            return;
        }

        $removedCount = $this->removeAssignmentsFromTicket($ticketId, $technicianId > 0 ? $technicianId : null);

        if ($redirectTo === 'detail') {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . ($removedCount > 0 ? '&unassigned=1' : '&err=' . urlencode('Aucune assignation à retirer.')));
            return;
        }

        $this->redirect($moduleBasePath . '?' . ($removedCount > 0 ? 'unassigned=1' : 'err=' . urlencode('Aucune assignation à retirer.')));
    }

    public function createManual(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $moduleBasePath = $isRaccordementClients ? '/raccordement-clients' : '/maintenance-ftth';

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $cutType = strtolower(trim((string)($_POST['cut_type'] ?? 'client')));
        if (!in_array($cutType, ['client', 'transport'], true)) {
            $cutType = 'client';
        }

        $refCode = trim((string)($_POST['ref_code'] ?? ''));
        $fixedLine = trim((string)($_POST['fixed_line'] ?? ''));
        $clientCode = trim((string)($_POST['client_code'] ?? ''));
        $clientName = trim((string)($_POST['client_name'] ?? ''));
        $clientPhone = trim((string)($_POST['client_phone'] ?? ''));
        $clientPhone2 = trim((string)($_POST['client_phone2'] ?? ''));
        $clientAddress = trim((string)($_POST['client_address'] ?? ''));
        $gpsRaw = trim((string)($_POST['gps'] ?? ''));
        [$clientLat, $clientLng] = $this->parseGpsCoordinates($gpsRaw);
        $raccordementCity = trim((string)($_POST['raccordement_city'] ?? ''));
        $raccordementCommune = trim((string)($_POST['raccordement_commune'] ?? ''));
        $raccordementLatitude = trim((string)($_POST['raccordement_latitude'] ?? ''));
        $raccordementLongitude = trim((string)($_POST['raccordement_longitude'] ?? ''));
        $sroClient = trim((string)($_POST['sro_client'] ?? ''));
        $jdvClient = trim((string)($_POST['jdv_client'] ?? ''));
        $pcoClient = trim((string)($_POST['pco_client'] ?? ''));
        $companyName = trim((string)($_POST['company_name'] ?? ''));
        $raccordementSnont = trim((string)($_POST['raccordement_snont'] ?? ''));
        $raccordementNd = trim((string)($_POST['raccordement_nd'] ?? ''));
        $raccordementDateEnregistrement = trim((string)($_POST['raccordement_date_enregistrement'] ?? ''));
        $raccordementDateEnvoiPrestataires = trim((string)($_POST['raccordement_date_envoi_prestataires'] ?? ''));
        $raccordementDureeAttente = trim((string)($_POST['raccordement_duree_attente'] ?? ''));
        $raccordementTeam = trim((string)($_POST['raccordement_team'] ?? ''));
        $raccordementEtudeTechnique = trim((string)($_POST['raccordement_etude_technique'] ?? ''));
        $transportRefCode = trim((string)($_POST['transport_ref_code'] ?? ''));
        $transportZone = trim((string)($_POST['transport_zone'] ?? ''));
        $transportOlt = trim((string)($_POST['transport_olt'] ?? ''));
        $transportSro = trim((string)($_POST['transport_sro'] ?? ''));
        $transportJdv = trim((string)($_POST['transport_jdv'] ?? ''));
        $transportPco = trim((string)($_POST['transport_pco'] ?? ''));
        $transportSignalDetails = trim((string)($_POST['transport_signal_details'] ?? ''));
        $avancement = trim((string)($_POST['avancement'] ?? ''));
        $status = $this->normalizeStatus((string)($_POST['status'] ?? 'nouveau'));
        $priority = $this->normalizePriority((string)($_POST['priority'] ?? 'Moyenne'));
        $description = trim((string)($_POST['description'] ?? ''));
        $causeComment = trim((string)($_POST['cause_comment'] ?? ''));
        $dateCloture = trim((string)($_POST['date_cloture'] ?? ''));
        $techIds = array_map('intval', (array)($_POST['technician_ids'] ?? []));
        $notes = trim((string)($_POST['assignment_notes'] ?? ''));

        if ($cutType === 'client' && $clientName === '') {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Le nom du client est obligatoire pour la déclaration manuelle.'));
            return;
        }

        if (!$this->requestMatchesAssignedCity($u, $raccordementCity, $raccordementCommune, $clientAddress ?: $transportZone)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('La ville saisie ne correspond pas à votre ville de rattachement.'));
            return;
        }

        if ($cutType === 'transport') {
            $refCode = $transportRefCode !== '' ? $transportRefCode : $refCode;
            $clientName = $transportZone !== '' ? 'Coupure transport - ' . $transportZone : 'Coupure transport';
            $clientCode = '';
            $clientPhone = '';
            $clientPhone2 = '';
            $clientAddress = $transportZone;
            $gpsRaw = '';
            $clientLat = null;
            $clientLng = null;
            $fixedLine = '';
            $sroClient = $transportSro;
            $jdvClient = $transportJdv;
            $pcoClient = $transportPco;
            $companyName = $transportOlt;
            $description = $transportSignalDetails;
            $avancement = $avancement !== '' ? $avancement : 'Coupure transport';
        }

        if ($isRaccordementClients && $cutType === 'client' && $raccordementLatitude !== '' && $raccordementLongitude !== '') {
            $clientLat = str_replace(',', '.', $raccordementLatitude);
            $clientLng = str_replace(',', '.', $raccordementLongitude);
            $gpsRaw = $clientLat . ', ' . $clientLng;
        }

        if ($refCode === '') {
            $refCode = 'MAN-' . date('YmdHis');
        }

        $natureIntervention = $cutType === 'transport'
            ? 'Coupure transport'
            : ($isRaccordementClients ? 'Raccordement client' : 'Coupure client');
        $extraFields = array_filter([
            'source' => 'manual',
            'module' => $isRaccordementClients ? 'raccordement-clients' : 'maintenance-ftth',
            'cut_type' => $cutType,
            'GPS' => $gpsRaw,
            'Zone' => $transportZone,
            'OLT de rattachement' => $transportOlt,
            'Détail de la signalisation' => $transportSignalDetails,
        ], static fn($value) => $value !== null && $value !== '');

        if ($isRaccordementClients && $cutType === 'client') {
            $extraFields = array_merge($extraFields, array_filter([
                'NumeroAbonnement' => $clientCode,
                'NomClient' => $clientName,
                'Ville' => $raccordementCity,
                'Commune' => $raccordementCommune,
                'Contact1' => $clientPhone,
                'Contact2' => $clientPhone2,
                'Localisation' => $clientAddress,
                'Latitude_Terrai' => $raccordementLatitude !== '' ? $raccordementLatitude : $clientLat,
                'Longitude_Terrain' => $raccordementLongitude !== '' ? $raccordementLongitude : $clientLng,
                'PLAQUE' => $sroClient,
                'JDV' => $jdvClient,
                'PCO' => $pcoClient,
                'SNONT' => $raccordementSnont,
                'ND' => $raccordementNd,
                'EntrepriseFTTH' => $companyName,
                'Etude_Technique' => $raccordementEtudeTechnique,
                'Commentaires' => $description,
                'Problèmes' => $causeComment,
                'Equipe_raccordement' => $raccordementTeam,
                'Date_Enregistrement_Instances' => $raccordementDateEnregistrement,
                'Date_Envoi_Instances_Prestataires' => $raccordementDateEnvoiPrestataires,
                'Duree_attente_j' => $raccordementDureeAttente,
            ], static fn($value) => $value !== null && $value !== ''));
        }

        $st = $this->pdo->prepare(
            'INSERT INTO maintenance_ftth_tickets
             (ref_code, fixed_line, client_name, client_code, client_phone, client_phone2,
              client_address, client_lat, client_lng, sro_client, jdv_client, pco_client,
              company_name, nature_intervention, description, cause_comment, priority, status,
              avancement, equipes, date_cloture, extra_fields, imported_at, imported_by)
             VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?)'
        );

        $st->execute([
            $refCode, $fixedLine ?: null, $clientName, $clientCode ?: null, $clientPhone ?: null, $clientPhone2 ?: null,
            $clientAddress ?: null, $clientLat, $clientLng, $sroClient ?: null, $jdvClient ?: null, $pcoClient ?: null,
            $companyName ?: null, $natureIntervention, $description ?: null, $causeComment ?: null, $priority, $status,
            $avancement ?: null, null, $dateCloture ?: null, json_encode($extraFields, JSON_UNESCAPED_UNICODE), (int)$u['id'],
        ]);

        $ticketId = (int)$this->pdo->lastInsertId();
        if (!empty($techIds)) {
            $this->assignTechniciansToTicket($ticketId, $techIds, (int)$u['id'], $notes);
        }

        $this->redirect($moduleBasePath . '/detail?id=' . $ticketId . '&created=1');
    }

    public function updateManual(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $moduleBasePath = $this->currentModuleBasePath();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        if (!$isRaccordementClients) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('La modification rapide est réservée au module Raccordement.'));
            return;
        }

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        if ($ticketId <= 0) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Raccordement introuvable pour modification.'));
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'edit')) {
            $this->redirect($moduleBasePath . '?err=forbidden');
            return;
        }

        $ticketSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticket = $ticketSt->fetch(PDO::FETCH_ASSOC);
        if (!$ticket || !$this->ticketMatchesModule($ticket, true)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Raccordement introuvable pour modification.'));
            return;
        }

        $refCode = trim((string)($_POST['ref_code'] ?? ''));
        $clientCode = trim((string)($_POST['client_code'] ?? ''));
        $clientName = trim((string)($_POST['client_name'] ?? ''));
        $clientPhone = trim((string)($_POST['client_phone'] ?? ''));
        $clientPhone2 = trim((string)($_POST['client_phone2'] ?? ''));
        $clientAddress = trim((string)($_POST['client_address'] ?? ''));
        $raccordementCity = trim((string)($_POST['raccordement_city'] ?? ''));
        $raccordementCommune = trim((string)($_POST['raccordement_commune'] ?? ''));
        $raccordementLatitude = trim((string)($_POST['raccordement_latitude'] ?? ''));
        $raccordementLongitude = trim((string)($_POST['raccordement_longitude'] ?? ''));
        $sroClient = trim((string)($_POST['sro_client'] ?? ''));
        $jdvClient = trim((string)($_POST['jdv_client'] ?? ''));
        $pcoClient = trim((string)($_POST['pco_client'] ?? ''));
        $companyName = trim((string)($_POST['company_name'] ?? ''));
        $raccordementSnont = trim((string)($_POST['raccordement_snont'] ?? ''));
        $raccordementNd = trim((string)($_POST['raccordement_nd'] ?? ''));
        $raccordementDateEnregistrement = trim((string)($_POST['raccordement_date_enregistrement'] ?? ''));
        $raccordementDateEnvoiPrestataires = trim((string)($_POST['raccordement_date_envoi_prestataires'] ?? ''));
        $raccordementDureeAttente = trim((string)($_POST['raccordement_duree_attente'] ?? ''));
        $raccordementTeam = trim((string)($_POST['raccordement_team'] ?? ''));
        $raccordementEtudeTechnique = trim((string)($_POST['raccordement_etude_technique'] ?? ''));
        $avancement = trim((string)($_POST['avancement'] ?? ''));
        $priority = $this->normalizePriority((string)($_POST['priority'] ?? ($ticket['priority'] ?? 'Moyenne')));
        $status = $this->normalizeStatus((string)($_POST['status'] ?? ($ticket['status'] ?? 'nouveau')));
        $description = trim((string)($_POST['description'] ?? ''));
        $causeComment = trim((string)($_POST['cause_comment'] ?? ''));

        if ($clientName === '') {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Le nom du client est obligatoire pour modifier le raccordement.'));
            return;
        }

        if (!$this->requestMatchesAssignedCity($u, $raccordementCity, $raccordementCommune, $clientAddress)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('La ville saisie ne correspond pas à votre ville de rattachement.'));
            return;
        }

        $clientLat = null;
        $clientLng = null;
        if ($raccordementLatitude !== '' && $raccordementLongitude !== '') {
            $clientLat = str_replace(',', '.', $raccordementLatitude);
            $clientLng = str_replace(',', '.', $raccordementLongitude);
        }

        $existingExtraFields = [];
        if (!empty($ticket['extra_fields'])) {
            $decoded = json_decode((string)$ticket['extra_fields'], true);
            if (is_array($decoded)) {
                $existingExtraFields = $decoded;
            }
        }

        $existingExtraFields['module'] = 'raccordement-clients';
        $existingExtraFields['NumeroAbonnement'] = $clientCode;
        $existingExtraFields['NomClient'] = $clientName;
        $existingExtraFields['Ville'] = $raccordementCity;
        $existingExtraFields['Commune'] = $raccordementCommune;
        $existingExtraFields['Contact1'] = $clientPhone;
        $existingExtraFields['Contact2'] = $clientPhone2;
        $existingExtraFields['Localisation'] = $clientAddress;
        $existingExtraFields['Latitude_Terrai'] = $raccordementLatitude !== '' ? $raccordementLatitude : ($clientLat ?? '');
        $existingExtraFields['Longitude_Terrain'] = $raccordementLongitude !== '' ? $raccordementLongitude : ($clientLng ?? '');
        $existingExtraFields['PLAQUE'] = $sroClient;
        $existingExtraFields['JDV'] = $jdvClient;
        $existingExtraFields['PCO'] = $pcoClient;
        $existingExtraFields['SNONT'] = $raccordementSnont;
        $existingExtraFields['ND'] = $raccordementNd;
        $existingExtraFields['EntrepriseFTTH'] = $companyName;
        $existingExtraFields['Etude_Technique'] = $raccordementEtudeTechnique;
        $existingExtraFields['Commentaires'] = $description;
        $existingExtraFields['Problèmes'] = $causeComment;
        $existingExtraFields['Equipe_raccordement'] = $raccordementTeam;
        $existingExtraFields['Date_Enregistrement_Instances'] = $raccordementDateEnregistrement;
        $existingExtraFields['Date_Envoi_Instances_Prestataires'] = $raccordementDateEnvoiPrestataires;
        $existingExtraFields['Duree_attente_j'] = $raccordementDureeAttente;

        foreach ($existingExtraFields as $key => $value) {
            if ($value === null || trim((string)$value) !== '') {
                continue;
            }
            unset($existingExtraFields[$key]);
        }

        $updateSt = $this->pdo->prepare(
            'UPDATE maintenance_ftth_tickets
             SET ref_code=?,
                 client_name=?,
                 client_code=?,
                 client_phone=?,
                 client_phone2=?,
                 client_address=?,
                 client_lat=?,
                 client_lng=?,
                 sro_client=?,
                 jdv_client=?,
                 pco_client=?,
                 company_name=?,
                 description=?,
                 cause_comment=?,
                 priority=?,
                 status=?,
                 avancement=?,
                 nature_intervention=?,
                 extra_fields=?
             WHERE id=? LIMIT 1'
        );

        $updateSt->execute([
            $refCode !== '' ? $refCode : ($ticket['ref_code'] ?: 'MAN-' . date('YmdHis')),
            $clientName,
            $clientCode !== '' ? $clientCode : null,
            $clientPhone !== '' ? $clientPhone : null,
            $clientPhone2 !== '' ? $clientPhone2 : null,
            $clientAddress !== '' ? $clientAddress : null,
            $this->hasValidCoordinates($clientLat, $clientLng) ? $clientLat : null,
            $this->hasValidCoordinates($clientLat, $clientLng) ? $clientLng : null,
            $sroClient !== '' ? $sroClient : null,
            $jdvClient !== '' ? $jdvClient : null,
            $pcoClient !== '' ? $pcoClient : null,
            $companyName !== '' ? $companyName : null,
            $description !== '' ? $description : null,
            $causeComment !== '' ? $causeComment : null,
            $priority,
            $status,
            $avancement !== '' ? $avancement : null,
            'Raccordement client',
            json_encode($existingExtraFields, JSON_UNESCAPED_UNICODE),
            $ticketId,
        ]);

        $summaryParts = array_values(array_filter([
            $clientName !== '' ? 'Client: ' . $clientName : null,
            $clientCode !== '' ? 'Abonnement: ' . $clientCode : null,
            $avancement !== '' ? 'Avancement: ' . $avancement : null,
            'Priorité: ' . $priority,
            'Statut: ' . $status,
        ]));
        $this->logTicketEvent(
            $ticketId,
            (int)($u['id'] ?? 0),
            'raccordement_updated',
            'Raccordement modifié',
            implode(' • ', $summaryParts),
            'info',
            'pen-to-square'
        );

        $this->redirect($moduleBasePath . '?updated=1');
    }

    public function requalifyPriority(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        if (!$this->isRaccordementClientsRequest()) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('La requalification de criticité est réservée au module Raccordement.'));
            return;
        }

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        $priority = $this->normalizePriority((string)($_POST['priority'] ?? 'Moyenne'));

        if ($ticketId <= 0) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Raccordement introuvable pour requalification.'));
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'edit')) {
            $this->redirect($moduleBasePath . '?err=forbidden');
            return;
        }

        $ticketSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticket = $ticketSt->fetch(PDO::FETCH_ASSOC);
        if (!$ticket || !$this->ticketMatchesModule($ticket, true)) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Raccordement introuvable pour requalification.'));
            return;
        }

        $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET priority=? WHERE id=? LIMIT 1')
            ->execute([$priority, $ticketId]);

        $this->logTicketEvent(
            $ticketId,
            (int)($u['id'] ?? 0),
            'priority_requalified',
            'Criticité requalifiée',
            'Nouvelle criticité : ' . $priority,
            'danger',
            'triangle-exclamation'
        );

        $this->redirect($moduleBasePath . '?priority_updated=1');
    }

    // ================================================================
    // WEB — VALIDATE (N+1)
    // ================================================================
    public function validate(): void
    {
        $u = $this->requireRoles(['admin', 'agent', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();
        if (!$this->canUserValidateCurrentModule($u)) {
            $this->redirect($moduleBasePath . '?err=forbidden');
            return;
        }

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $reportId = (int)($_POST['report_id'] ?? $_POST['id_report'] ?? $_GET['report_id'] ?? $_GET['id_report'] ?? 0);
        $ticketId = (int)($_POST['ticket_id'] ?? $_GET['ticket_id'] ?? $_POST['id'] ?? $_GET['id'] ?? 0);
        $action   = (string)($_POST['action'] ?? $_GET['action'] ?? '');  // 'validate' ou 'reject'
        $reason   = trim($_POST['reason'] ?? '');

        if ($ticketId <= 0) {
            $referer = (string)($_SERVER['HTTP_REFERER'] ?? '');
            if ($referer !== '') {
                $query = parse_url($referer, PHP_URL_QUERY);
                if (is_string($query) && $query !== '') {
                    parse_str($query, $refererQuery);
                    $ticketId = (int)($refererQuery['ticket_id'] ?? $refererQuery['id'] ?? 0);
                }

                if ($ticketId <= 0) {
                    $refererPath = (string)(parse_url($referer, PHP_URL_PATH) ?? '');
                    if (preg_match('~/(?:maintenance-ftth|raccordement-clients)/(?:detail/)?(\d+)$~', $refererPath, $m)) {
                        $ticketId = (int)$m[1];
                    }
                }
            }
        }

        if ($ticketId <= 0 && session_status() === PHP_SESSION_ACTIVE) {
            if ($this->isRaccordementClientsRequest()) {
                $ticketId = (int)($_SESSION['last_raccordement_ticket_id'] ?? 0);
            } else {
                $ticketId = (int)($_SESSION['last_maintenance_ticket_id'] ?? 0);
            }
        }

        // Fallback: certains écrans peuvent poster ticket_id sans report_id.
        if ($reportId <= 0 && $ticketId > 0) {
            $fallbackReportStatement = $this->pdo->prepare(
                'SELECT id
                 FROM maintenance_ftth_reports
                 WHERE ticket_id = ? AND id > 0
                 ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
                 LIMIT 1'
            );
            $fallbackReportStatement->execute([$ticketId]);
            $reportId = (int)($fallbackReportStatement->fetchColumn() ?: 0);
        }

        if ($action === '') {
            $action = 'validate';
        }

        if ($reportId <= 0) {
            $this->redirect($moduleBasePath . '?err=missing');
            return;
        }

        $repSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_reports WHERE id=? LIMIT 1');
        $repSt->execute([$reportId]);
        $report = $repSt->fetch(PDO::FETCH_ASSOC);
        if (!$report) {
            $this->redirect($moduleBasePath . '?err=notfound');
            return;
        }

        if (!$this->canAccessMaintenanceTicket((int)$report['ticket_id'], $u, 'view')) {
            $this->redirect($this->currentModuleBasePath() . '?err=forbidden');
            return;
        }

        $isStudyValidation = $this->isRaccordementClientsRequest()
            && (($report['workflow_phase'] ?? 'study') === 'study')
            && (($report['status'] ?? '') === 'soumis');

        if ($action === 'validate' && $isStudyValidation) {
            $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports
                 SET status=?, workflow_phase=?, validated_at=NULL, validated_by=NULL, rejection_reason=NULL, installation_sent_at=NULL
                 WHERE id=?'
            )->execute(['brouillon', 'installation', $reportId]);

            $this->pdo->prepare(
                'UPDATE maintenance_ftth_tickets SET status=?, avancement=? WHERE id=?'
            )->execute(['attente_planification', 'En attente d\'assignation raccordement', (int)$report['ticket_id']]);

            $this->logTicketEvent(
                (int)$report['ticket_id'],
                (int)$u['id'],
                'study_validated',
                'Étude validée',
                'Le superviseur a validé l\'étude et ouvert la phase raccordement.',
                'success',
                'circle-check'
            );
        } elseif ($action === 'validate') {
            $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports SET status=?, validated_at=NOW(), validated_by=?, rejection_reason=NULL WHERE id=?'
            )->execute(['validé', (int)$u['id'], $reportId]);

            RaccordementOtbService::syncOccupancyFromReport($this->pdo, $reportId);

            $this->pdo->prepare(
                'UPDATE maintenance_ftth_tickets SET status=? WHERE id=?'
            )->execute(['validé', (int)$report['ticket_id']]);
            if ($this->isRaccordementClientsRequest()) {
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET avancement=? WHERE id=?')
                    ->execute(['Validation finale raccordement effectuée', (int)$report['ticket_id']]);
            }

            try {
                $publicClientSt = $this->pdo->prepare('SELECT public_client_id FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
                $publicClientSt->execute([(int)$report['ticket_id']]);
                $publicClientId = (int)($publicClientSt->fetchColumn() ?: 0);
                if ($publicClientId > 0) {
                    $this->pdo->prepare("UPDATE public_mobile_incidents SET status='resolu', updated_at=NOW() WHERE ticket_id=? AND status <> 'ferme'")
                        ->execute([(int)$report['ticket_id']]);
                    Notifier::notifyPublicClients($this->pdo, [$publicClientId], '[e-Intervention] Incident résolu', 'Votre demande liée à l\'intervention FTTH a été validée comme résolue.', '#/history');
                }
            } catch (\Throwable $e) {
            }

            Notifier::notifyUsers($this->pdo, [(int)$report['technician_id']],
                '[FTTH] Intervention validée',
                'Votre rapport d\'intervention a été validé.',
                $moduleBasePath . '/detail?id=' . $report['ticket_id']
            );
        } elseif ($action === 'reject' && $isStudyValidation) {
            $studyTechnicianId = (int)($report['study_technician_id'] ?? $report['technician_id'] ?? 0);

            $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports
                 SET status=?, workflow_phase=?, validated_at=NULL, validated_by=NULL, rejection_reason=?, study_submitted_at=NULL, installation_sent_at=NULL, installation_technician_id=NULL, selected_otb_port_status=?
                 WHERE id=?'
            )->execute(['rejeté', 'study', $reason, 'none', $reportId]);

            RaccordementOtbService::syncOccupancyFromReport($this->pdo, $reportId);

            $this->removeAssignmentsFromTicket((int)$report['ticket_id'], null);
            if ($studyTechnicianId > 0) {
                $this->assignTechniciansToTicket((int)$report['ticket_id'], [$studyTechnicianId], (int)$u['id'], 'Retour étude après rejet superviseur');
            }
            $this->pdo->prepare(
                'UPDATE maintenance_ftth_tickets SET status=?, avancement=? WHERE id=?'
            )->execute(['assigné', 'Étude à corriger', (int)$report['ticket_id']]);

            $this->logTicketEvent(
                (int)$report['ticket_id'],
                (int)$u['id'],
                'study_rejected',
                'Étude rejetée',
                'Le dossier est retourné au technicien étude pour correction.',
                'danger',
                'rotate-left'
            );
        } elseif ($action === 'reject') {
            $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports SET status=?, validated_at=NULL, validated_by=NULL, rejection_reason=? WHERE id=?'
            )->execute(['rejeté', $reason, $reportId]);

            $this->pdo->prepare(
                'UPDATE maintenance_ftth_tickets SET status=? WHERE id=?'
            )->execute(['en_cours', (int)$report['ticket_id']]);
            if ($this->isRaccordementClientsRequest()) {
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET avancement=? WHERE id=?')
                    ->execute(['Installation à corriger', (int)$report['ticket_id']]);
            }

            Notifier::notifyUsers($this->pdo, [(int)$report['technician_id']],
                '[FTTH] Rapport rejeté',
                'Votre rapport a été rejeté. Motif : ' . $reason,
                $moduleBasePath . '/detail?id=' . $report['ticket_id']
            );
        }

        $flashFlag = $action === 'reject'
            ? 'rejected=1'
            : ($isStudyValidation ? 'study_validated=1' : 'validated=1');
        $this->redirect($moduleBasePath . '/detail?id=' . $report['ticket_id'] . '&' . $flashFlag);
    }

    public function closeTicket(): void
    {
        $u = $this->requireRoles(['admin', 'manager', 'superviseur', 'supervisor']);
        $moduleBasePath = $this->currentModuleBasePath();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect($moduleBasePath);
            return;
        }

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        if ($ticketId <= 0) {
            $this->redirect($moduleBasePath . '?err=missing');
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'close')) {
            $this->redirect($moduleBasePath . '?err=forbidden');
            return;
        }

        $ticketSt = $this->pdo->prepare('SELECT id, status, public_client_id, client_name FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticket = $ticketSt->fetch(PDO::FETCH_ASSOC);
        if (!$ticket) {
            $this->redirect($moduleBasePath . '?err=notfound');
            return;
        }

        $reportStatusSt = $this->pdo->prepare('SELECT status FROM maintenance_ftth_reports WHERE ticket_id=? LIMIT 1');
        $reportStatusSt->execute([$ticketId]);
        $reportStatus = (string)($reportStatusSt->fetchColumn() ?: '');

        $canClose = (($ticket['status'] ?? '') === 'validé') || $reportStatus === 'validé';
        if (!$canClose) {
            $this->redirect($moduleBasePath . '/detail?id=' . $ticketId . '&err=' . urlencode('Le ticket doit être validé avant clôture.'));
            return;
        }

        if (($ticket['status'] ?? '') !== 'clôturé') {
            if ($this->isRaccordementClientsRequest()) {
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=?, avancement=? WHERE id=?')
                    ->execute(['clôturé', 'Dossier clôturé', $ticketId]);
            } else {
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
                    ->execute(['clôturé', $ticketId]);
            }

            try {
                $publicClientId = (int)($ticket['public_client_id'] ?? 0);
                if ($publicClientId > 0) {
                    $this->pdo->prepare("UPDATE public_mobile_incidents SET status='ferme', updated_at=NOW() WHERE ticket_id=?")
                        ->execute([$ticketId]);
                    Notifier::notifyPublicClients(
                        $this->pdo,
                        [$publicClientId],
                        '[e-Intervention] Incident clôturé',
                        'Votre demande FTTH pour ' . trim((string)($ticket['client_name'] ?? 'ce client')) . ' a été clôturée.',
                        '#/history'
                    );
                }
            } catch (\Throwable $e) {
            }
        }

        $this->redirect($moduleBasePath . '/detail?id=' . $ticketId . '&closed=1');
    }

    // ================================================================
    // WEB — DELETE TICKET
    // ================================================================
    public function deleteTicket(): void
    {
        $id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
        $u = $this->requireRoles(['admin', 'manager']);

        if (!$this->canAccessMaintenanceTicket($id, $u, 'delete')) {
            $this->redirect('/maintenance-ftth?err=forbidden');
            return;
        }

        $deletedCount = $this->deleteTicketsByIds([$id]);
        $this->redirect('/maintenance-ftth?' . ($deletedCount > 0 ? 'deleted=1' : 'err=' . urlencode('Ticket introuvable ou déjà supprimé.')));
    }

    public function bulkDelete(): void
    {
        $u = $this->requireRoles(['admin', 'manager']);

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->redirect('/maintenance-ftth');
            return;
        }

        $ticketIds = $this->parseSelectedTicketIds($_POST['selected_ticket_ids'] ?? []);
        if (empty($ticketIds)) {
            $this->redirect('/maintenance-ftth?err=' . urlencode('Aucun ticket sélectionné pour la suppression.'));
            return;
        }

        $ticketIds = $this->filterAccessibleMaintenanceTicketIds($ticketIds, $u, $this->resolveMaintenanceScope('delete', $u));
        if (empty($ticketIds)) {
            $this->redirect('/maintenance-ftth?err=' . urlencode('Aucun ticket supprimable dans votre ville de rattachement.'));
            return;
        }

        $deletedCount = $this->deleteTicketsByIds($ticketIds);
        $this->redirect('/maintenance-ftth?bulk_deleted=' . $deletedCount);
    }

    public function selectionReport(): void
    {
        $u = $this->requireAuth();
        $viewScope = $this->resolveMaintenanceScope('view', $u);
        if ($viewScope === 'none') {
            http_response_code(403);
            exit('Accès refusé.');
        }
        $ticketIds = $this->parseSelectedTicketIds($_GET['ids'] ?? $_GET['selected_ticket_ids'] ?? []);
        $ticketIds = $this->filterAccessibleMaintenanceTicketIds($ticketIds, $u, $viewScope);
        $mode = strtolower(trim((string)($_GET['mode'] ?? 'group')));
        if (!in_array($mode, ['single', 'group'], true)) {
            $mode = 'group';
        }

        if (empty($ticketIds)) {
            $this->redirect('/maintenance-ftth?err=' . urlencode('Sélectionnez au moins un ticket pour générer le rapport.'));
            return;
        }

        if ($mode === 'single' && count($ticketIds) !== 1) {
            $this->redirect('/maintenance-ftth?err=' . urlencode('Le rapport individuel nécessite la sélection d’un seul ticket.'));
            return;
        }

        $tickets = $this->fetchTicketsForSelectionReport($ticketIds);
        if (empty($tickets)) {
            $this->redirect('/maintenance-ftth?err=' . urlencode('Impossible de générer le rapport pour la sélection demandée.'));
            return;
        }

        $this->view('maintenance_ftth/report_selection', [
            'title' => $mode === 'single' ? 'Rapport FTTH individuel' : 'Rapport FTTH groupé',
            'tickets' => $tickets,
            'mode' => $mode,
            'generatedAt' => (new \DateTimeImmutable('now'))->format('d/m/Y H:i'),
            'user' => $u,
            '_layout' => 'none',
        ]);
    }

    // ================================================================
    //  MOBILE API — GET /api/mobile/ftth/assigned
    // ================================================================
    public function apiAssigned(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }

        if (!$this->mobileUserCanAccessFtthPortfolio($user)) {
            $this->jsonOk(['ok' => true, 'tickets' => []]);
            return;
        }

        try {
            $where = ["t.status NOT IN ('validé','clôturé')"];
            $params = [(int)$user['id']];

            $technicianType = $this->resolveMobileTechnicianType($user);
            if ($technicianType === 'raccordement') {
                $where[] = $this->maintenanceModuleWhereClause(true, 't');
            } elseif ($technicianType === 'maintenance_ftth') {
                $where[] = $this->maintenanceModuleWhereClause(false, 't');
            }

            $st = $this->pdo->prepare(
                'SELECT t.id, t.ref_code, t.client_name, t.client_code, t.fixed_line,
                        t.client_phone, t.client_phone2, t.client_address,
                        t.client_lat, t.client_lng, t.site_name, t.nature_intervention,
                        t.description, t.priority, t.status, t.avancement,
                        t.sro_client, t.jdv_client, t.pco_client, t.company_name,
                        t.extra_fields, t.imported_at,
                           a.assigned_at, a.notes AS assign_notes,
                           (SELECT status FROM maintenance_ftth_reports WHERE ticket_id=t.id LIMIT 1) AS report_status,
                           (SELECT workflow_phase FROM maintenance_ftth_reports WHERE ticket_id=t.id LIMIT 1) AS report_workflow_phase,
                           (SELECT client_visit_status FROM maintenance_ftth_reports WHERE ticket_id=t.id LIMIT 1) AS client_visit_status,
                           (SELECT planned_intervention_date FROM maintenance_ftth_reports WHERE ticket_id=t.id LIMIT 1) AS planned_intervention_date,
                           (SELECT planned_intervention_time FROM maintenance_ftth_reports WHERE ticket_id=t.id LIMIT 1) AS planned_intervention_time
                 FROM maintenance_ftth_tickets t
                 JOIN maintenance_ftth_assignments a ON a.ticket_id = t.id AND a.technician_id = ?
                  WHERE ' . implode(' AND ', $where) . '
                 ORDER BY t.priority DESC, a.assigned_at DESC'
            );
              $st->execute($params);
            $tickets = $st->fetchAll(PDO::FETCH_ASSOC);
            $tickets = array_map(function (array $ticket): array {
                $ticket = $this->hydrateTicketImportFallbacks($ticket);
                $ticket['mobile_is_raccordement'] = $this->ticketUsesRaccordementMobileUi($ticket);
                $coordinates = $this->resolveTicketClientCoordinates($ticket);
                if ($this->hasValidCoordinates($coordinates['lat'], $coordinates['lng'])) {
                    $ticket['client_lat'] = $coordinates['lat'];
                    $ticket['client_lng'] = $coordinates['lng'];
                }
                return $ticket;
            }, $tickets ?: []);
        } catch (\Throwable $e) {
            $tickets = [];
        }

        $this->jsonOk(['ok' => true, 'tickets' => $tickets]);
    }

    public function apiNearbyClients(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }

        if (!$this->mobileUserCanAccessNearbyClients($user)) {
            $this->jsonOk(['ok' => true, 'clients' => []]);
            return;
        }

        try {
            $where = ['1=1'];
            $params = [(int)$user['id']];
            $this->appendAssignedCityFilter($where, $params, $user, 't');

            $st = $this->pdo->prepare(
                'SELECT t.id,
                        t.ref_code,
                        t.client_name,
                        t.client_phone,
                        t.client_address,
                        t.site_name,
                    t.extra_fields,
                        t.nature_intervention,
                        t.priority,
                        t.status,
                    t.client_lat,
                    t.client_lng,
                        r.operation_type,
                        r.status AS report_status,
                        r.client_visit_status,
                        r.planned_intervention_date,
                        r.planned_intervention_time,
                        COALESCE(r.submitted_at, r.updated_at, r.created_at) AS last_reported_at,
                        CASE
                            WHEN LOWER(COALESCE(r.operation_type, "")) = "maintenance"
                              OR LOWER(COALESCE(t.nature_intervention, "")) LIKE "%maintenance%"
                            THEN 1 ELSE 0
                        END AS is_maintenance,
                        CASE
                            WHEN (
                                LOWER(COALESCE(r.operation_type, "")) = "maintenance"
                                OR LOWER(COALESCE(t.nature_intervention, "")) LIKE "%maintenance%"
                            )
                            AND t.status IN ("nouveau", "assigné", "en_cours", "attente_planification")
                            THEN 1 ELSE 0
                        END AS has_active_maintenance,
                        CASE
                            WHEN EXISTS(
                                SELECT 1 FROM maintenance_ftth_assignments a
                                WHERE a.ticket_id = t.id AND a.technician_id = ?
                            ) THEN 1 ELSE 0
                        END AS assigned_to_me
                 FROM maintenance_ftth_tickets t
                 INNER JOIN maintenance_ftth_reports r ON r.ticket_id = t.id
                  WHERE ' . implode(' AND ', $where) . '
                 ORDER BY has_active_maintenance DESC,
                          is_maintenance DESC,
                          last_reported_at DESC,
                          t.client_name ASC'
            );
              $st->execute($params);
            $clients = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];

            $clients = array_values(array_filter(array_map(function (array $client): ?array {
                $coordinates = $this->resolveTicketClientCoordinates($client);
                if (!$this->hasValidCoordinates($coordinates['lat'], $coordinates['lng'])) {
                    return null;
                }

                $client['client_lat'] = $coordinates['lat'];
                $client['client_lng'] = $coordinates['lng'];
                $client['coordinate_source'] = $coordinates['source'];
                unset($client['extra_fields']);

                return $client;
            }, $clients)));
        } catch (\Throwable $e) {
            $clients = [];
        }

        $this->jsonOk(['ok' => true, 'clients' => $clients]);
    }

    // ================================================================
    //  MOBILE API — GET /api/mobile/ftth/report?id={ticketId}
    // ================================================================
    public function apiReportGet(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }

        $ticketId = (int)($_GET['id'] ?? 0);
        if ($ticketId <= 0) {
            $this->jsonOk(['ok' => false, 'error' => 'missing_id'], 400);
            return;
        }

        try {
            if (!$this->mobileUserHasAssignedTicketAccess($ticketId, $user)) {
                $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
                return;
            }

            $tkSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
            $tkSt->execute([$ticketId]);
            $ticket = $tkSt->fetch(PDO::FETCH_ASSOC) ?: null;
            if (is_array($ticket)) {
                if (!$this->mobileUserTicketMatchesPortfolio($ticket, $user)) {
                    $techType = $this->resolveMobileTechnicianType($user);
                    $this->jsonOk(['ok' => false, 'error' => 'wrong_portfolio', 'message' => 'Ce ticket n\'appartient pas à votre portefeuille (' . $techType . '). Retournez à la liste.'], 403);
                    return;
                }
                $ticket = $this->hydrateTicketImportFallbacks($ticket);
                // Un technicien raccordement n'accède qu'aux tickets raccordement : on se fie au type
                $technicianTypeForReport = $this->resolveMobileTechnicianType($user);
                $ticket['mobile_is_raccordement'] = $technicianTypeForReport === 'raccordement'
                    || $this->ticketUsesRaccordementMobileUi($ticket);
            }

            $repSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_reports WHERE ticket_id=? LIMIT 1');
            $repSt->execute([$ticketId]);
            $report = $repSt->fetch(PDO::FETCH_ASSOC) ?: null;

            $isRaccordementTicket = is_array($ticket) && (bool)($ticket['mobile_is_raccordement'] ?? false);

            if ($report) {
                $report['impacted_equipment'] = json_decode($report['impacted_equipment'] ?? 'null', true) ?: [];
                $report['materials_used']     = json_decode($report['materials_used'] ?? 'null', true) ?: [];

                if (!$isRaccordementTicket) {
                    $report['selected_otb_id'] = null;
                    $report['selected_otb_label'] = null;
                    $report['recommended_otb_id'] = null;
                    $report['recommended_otb_label'] = null;
                    $report['otb_distance_meters'] = null;
                    $report['otb_selection_mode'] = 'none';
                    $report['otb_recommendation_payload'] = null;
                }

                $phSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_photos WHERE report_id=? ORDER BY tab_type, id');
                $phSt->execute([$report['id']]);
                $photos = $phSt->fetchAll(PDO::FETCH_ASSOC);
                foreach ($photos as &$ph) {
                    $ph['photo_url'] = base_url('storage/uploads/' . $this->uploadPath($ticketId) . $ph['photo_path']);
                }
                $report['photos'] = $photos;
            }

            $otb = null;
            if ($isRaccordementTicket) {
                $coords = $this->resolveTicketClientCoordinates($ticket);
                $zoneKey = trim((string)($ticket['dispatch_zone_key'] ?? ''));
                try {
                    $otb = RaccordementOtbService::buildRecommendation($this->pdo, $coords['lat'], $coords['lng'], $zoneKey);
                } catch (\Throwable $e) {
                    $otb = null; // OTB indisponible — la fiche terrain se charge sans recommandation
                }

                if (is_array($otb) && $report && (int)($report['selected_otb_id'] ?? 0) > 0) {
                    try {
                        $selectedBox = RaccordementOtbService::findBoxById($this->pdo, (int)$report['selected_otb_id']);
                        if ($selectedBox) {
                            $exists = false;
                            foreach (($otb['candidates'] ?? []) as $candidate) {
                                if ((int)($candidate['id'] ?? 0) === (int)$selectedBox['id']) {
                                    $exists = true;
                                    break;
                                }
                            }
                            if (!$exists) {
                                array_unshift($otb['candidates'], $selectedBox);
                            }
                        }
                    } catch (\Throwable $e) {
                        // Boîtier sélectionné introuvable — on continue sans injecter
                    }
                }
            }
        } catch (\Throwable $e) {
            $this->jsonOk(['ok' => false, 'error' => 'server_error', 'message' => $e->getMessage()], 500);
            return;
        }

        // Pour raccordement en phase étude : ports disponibles + techniciens handoff + tous boîtiers pour recherche manuelle
        $studyExtras = null;
        if ($isRaccordementTicket && ($report['workflow_phase'] ?? 'study') === 'study') {
            try {
                $selectedOtbIdForPorts = (int)($report['selected_otb_id'] ?? 0);
                $availablePorts = $selectedOtbIdForPorts > 0
                    ? \App\Core\RaccordementOtbService::getAvailablePorts($this->pdo, $selectedOtbIdForPorts, (int)($report['id'] ?? 0) ?: null)
                    : [];
                $handoffTechnicians = method_exists($this, 'resolveRaccordementHandoffTechnicians')
                    ? $this->resolveRaccordementHandoffTechnicians($ticket, $ticketId, (int)($user['id'] ?? 0))
                    : [];

                // Tous les boîtiers actifs triés par distance pour la recherche manuelle mobile
                $ticketCoordsForBoxes = $this->resolveTicketClientCoordinates($ticket);
                $allBoxes = \App\Core\RaccordementOtbService::getAllBoxesSortedByDistance(
                    $this->pdo,
                    $ticketCoordsForBoxes['lat'] ?? null,
                    $ticketCoordsForBoxes['lng'] ?? null,
                    60
                );

                // Ports indexés par box_id pour toutes les boîtes retournées
                $reportIdForPorts = (int)($report['id'] ?? 0) ?: null;
                $portsByBox = [];
                foreach ($allBoxes as $box) {
                    $boxId = (int)($box['id'] ?? 0);
                    if ($boxId > 0) {
                        $portsByBox[(string)$boxId] = \App\Core\RaccordementOtbService::getAvailablePorts($this->pdo, $boxId, $reportIdForPorts);
                    }
                }

                $studyExtras = [
                    'available_ports'        => $availablePorts,
                    'handoff_technicians'    => $handoffTechnicians,
                    'selected_otb_port_number' => (int)($report['selected_otb_port_number'] ?? 0),
                    'all_boxes'              => $allBoxes,
                    'ports_by_box'           => $portsByBox,
                ];
            } catch (\Throwable $e) {
                $studyExtras = ['available_ports' => [], 'handoff_technicians' => [], 'selected_otb_port_number' => 0, 'all_boxes' => [], 'ports_by_box' => []];
            }
        }

        // Pour raccordement en phase installation : résumé de l'étude pour le technicien raccordement
        $installationExtras = null;
        if ($isRaccordementTicket && ($report['workflow_phase'] ?? 'study') === 'installation') {
            try {
                $selectedBoxIdForInstall = (int)($report['selected_otb_id'] ?? 0);
                $selectedBoxForInstall = null;
                if ($selectedBoxIdForInstall > 0) {
                    $selectedBoxForInstall = \App\Core\RaccordementOtbService::findBoxById($this->pdo, $selectedBoxIdForInstall);
                }
                $installationExtras = [
                    'selected_otb_id'    => $selectedBoxIdForInstall,
                    'selected_box_label' => $selectedBoxForInstall
                        ? ($selectedBoxForInstall['label'] ?? 'Boîtier #' . $selectedBoxIdForInstall)
                        : null,
                    'selected_box_address' => $selectedBoxForInstall
                        ? ($selectedBoxForInstall['address'] ?? null)
                        : null,
                    'selected_port'      => (int)($report['selected_otb_port_number'] ?? 0),
                    'study_gps_lat'      => $report['study_gps_lat'] ?? null,
                    'study_gps_lng'      => $report['study_gps_lng'] ?? null,
                    'planning_comment'   => $report['planning_comment'] ?? '',
                ];
            } catch (\Throwable $e) {
                $installationExtras = null;
            }
        }

        // Flag verrouillage : technicien étude qui a déjà transmis (workflow_phase=installation)
        $isHandoffLocked = false;
        if ($isRaccordementTicket && is_array($report)) {
            $reportPhase = (string)($report['workflow_phase'] ?? 'study');
            if ($reportPhase === 'installation') {
                $isHandoffLocked = true;
            }
        }

        $this->jsonOk(['ok' => true, 'ticket' => $ticket, 'report' => $report, 'otb' => $otb, 'study' => $studyExtras, 'installation' => $installationExtras, 'handoff_locked' => $isHandoffLocked]);
    }

    // ================================================================
    //  MOBILE API — POST /api/mobile/ftth/report/save
    // ================================================================
    // ================================================================
    public function apiReportSave(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }

        $ticketId  = (int)($_POST['ticket_id'] ?? 0);
        $rawAction  = trim($_POST['action_type'] ?? 'draft');
        $actionType = in_array($rawAction, ['draft', 'submit', 'handoff'], true) ? $rawAction : 'draft';

        if ($ticketId <= 0) {
            $this->jsonOk(['ok' => false, 'error' => 'missing_ticket_id'], 400);
            return;
        }

        $ticketStatement = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketStatement->execute([$ticketId]);
        $ticket = $ticketStatement->fetch(PDO::FETCH_ASSOC) ?: null;
        if (!$ticket || !$this->mobileUserTicketMatchesPortfolio($ticket, $user)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }
        $isRaccordementTicket = $this->ticketUsesRaccordementMobileUi($ticket);

        if (!$this->mobileUserHasAssignedTicketAccess($ticketId, $user)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        try {

        $destDir  = $this->uploadDir($ticketId);
        $pathBase = $this->uploadPath($ticketId);

        // --- Champs spécifiques phase étude raccordement ---
        $studyGpsLat = is_numeric($_POST['study_gps_lat'] ?? null) ? (float)$_POST['study_gps_lat'] : null;
        $studyGpsLng = is_numeric($_POST['study_gps_lng'] ?? null) ? (float)$_POST['study_gps_lng'] : null;
        $studyGpsAccuracyMeters = is_numeric($_POST['study_gps_accuracy_meters'] ?? null) ? round((float)$_POST['study_gps_accuracy_meters'], 2) : null;
        $selectedOtbPortNumber = (int)($_POST['selected_otb_port_number'] ?? 0);
        $requestedHandoffTechnicianId = (int)($_POST['handoff_technician_id'] ?? 0);

        // --- Onglet 1 : Constat ---
        $constatComments = trim($_POST['constat_comments'] ?? '');

        // Équipements impactés (JSON envoyé par le mobile)
        $impactedEq = json_decode($_POST['impacted_equipment'] ?? '[]', true) ?: [];

        // --- Onglet 2 : Intervention ---
        $materialsUsed  = json_decode($_POST['materials_used'] ?? '[]', true) ?: [];
        $generalComments = trim($_POST['general_comments'] ?? '');
        $selectedOtbId = (int)($_POST['selected_otb_id'] ?? 0);
        $recommendedOtbId = (int)($_POST['recommended_otb_id'] ?? 0);
        $otbDistanceMeters = is_numeric($_POST['otb_distance_meters'] ?? null) ? round((float)$_POST['otb_distance_meters'], 2) : null;
        $otbSelectionMode = trim((string)($_POST['otb_selection_mode'] ?? 'none'));
        if (!in_array($otbSelectionMode, ['auto', 'manual', 'none'], true)) {
            $otbSelectionMode = 'none';
        }

        if (!$isRaccordementTicket) {
            $selectedOtbId = 0;
            $recommendedOtbId = 0;
            $otbDistanceMeters = null;
            $otbSelectionMode = 'none';
        }

        $selectedOtb = $selectedOtbId > 0 ? RaccordementOtbService::findBoxById($this->pdo, $selectedOtbId) : null;
        $recommendedOtb = $recommendedOtbId > 0 ? RaccordementOtbService::findBoxById($this->pdo, $recommendedOtbId) : null;
        $selectedOtbLabel = $selectedOtb['box_code'] ?? null;
        $recommendedOtbLabel = $recommendedOtb['box_code'] ?? null;

        // --- Onglet 3 : Appréciation ---
        $satService   = $_POST['satisfaction_service']  ?? null;
        $satDelay     = $_POST['satisfaction_delay']    ?? null;
        $satQuality   = $_POST['satisfaction_quality']  ?? null;
        $satBehavior  = $_POST['satisfaction_behavior'] ?? null;
        $clientNameOS = trim($_POST['client_name_onsite'] ?? '');
        $clientVisitStatus = $this->normalizeClientVisitStatus($_POST['client_visit_status'] ?? null);
        $plannedInterventionDate = $this->normalizeOptionalDateValue($_POST['planned_intervention_date'] ?? null);
        $plannedInterventionTime = $this->normalizeOptionalTimeValue($_POST['planned_intervention_time'] ?? null);
        $planningComment = trim((string)($_POST['planning_comment'] ?? ''));
        $clientRemarks = trim($_POST['client_remarks'] ?? '');
        $serviceRestored = (int)($_POST['service_restored'] ?? 0);

        if ($clientVisitStatus === 'reprogrammation_demandee' && (!$plannedInterventionDate || !$plannedInterventionTime)) {
            $this->jsonOk(['ok' => false, 'error' => 'missing_planning_slot'], 422);
            return;
        }

        // --- Onglet 4 : Signatures ---
        $clientSigPath = null;
        $agentSigPath  = null;
        if (!empty($_POST['client_signature_data'])) {
            $clientSigPath = $this->saveBase64Image($_POST['client_signature_data'], $destDir, 'sig_client');
        }
        if (!empty($_POST['agent_signature_data'])) {
            $agentSigPath = $this->saveBase64Image($_POST['agent_signature_data'], $destDir, 'sig_agent');
        }

        // GPS
        $gpsLat = $_POST['gps_lat'] ?? null;
        $gpsLng = $_POST['gps_lng'] ?? null;
        $gpsAccuracyMeters = is_numeric($_POST['gps_accuracy_meters'] ?? null) ? round((float)$_POST['gps_accuracy_meters'], 2) : null;

        // En-tête
        $opType     = in_array($_POST['operation_type'] ?? '', ['maintenance','travaux','autres']) ? $_POST['operation_type'] : 'maintenance';
        $intervDate = $_POST['intervention_date'] ?? null;
        $intervStart = $_POST['intervention_start'] ?? null;
        $intervEnd   = $_POST['intervention_end'] ?? null;

        $workflow = $this->resolveFtthWorkflow($clientVisitStatus, $actionType);
        $statusNew = $workflow['report_status'];

        // Upsert du rapport
        $existingSt = $this->pdo->prepare(
            'SELECT id, workflow_phase, study_technician_id, installation_technician_id,
                    study_submitted_at, installation_sent_at, selected_otb_port_status
             FROM maintenance_ftth_reports WHERE ticket_id=? LIMIT 1'
        );
        $existingSt->execute([$ticketId]);
        $existingReport = $existingSt->fetch(\PDO::FETCH_ASSOC) ?: null;
        $existingId = (int)($existingReport['id'] ?? 0);

        // Valeurs de workflow phase initiales
        $now = new \DateTimeImmutable('now');
        $workflowPhase = $isRaccordementTicket ? (string)($existingReport['workflow_phase'] ?? 'study') : 'installation';
        $studyTechnicianId = (int)($existingReport['study_technician_id'] ?? 0);
        $installationTechnicianId = (int)($existingReport['installation_technician_id'] ?? 0);
        $studySubmittedAt = $existingReport['study_submitted_at'] ?? null;
        $installationSentAt = $existingReport['installation_sent_at'] ?? null;
        $selectedOtbPortStatus = (string)($existingReport['selected_otb_port_status'] ?? 'none');

        // --- Logique handoff raccordement ---
        if ($isRaccordementTicket && $actionType === 'handoff') {
            if ($selectedOtbId <= 0 || !$selectedOtb) {
                $this->jsonOk(['ok' => false, 'error' => 'validation_failed', 'message' => 'Sélectionnez un boîtier intelligent avant transmission.'], 422);
                return;
            }
            if ($selectedOtbPortNumber <= 0) {
                $this->jsonOk(['ok' => false, 'error' => 'validation_failed', 'message' => 'Réservez un port actif avant transmission.'], 422);
                return;
            }
            if (!$this->hasValidCoordinates($studyGpsLat, $studyGpsLng)) {
                $this->jsonOk(['ok' => false, 'error' => 'validation_failed', 'message' => "La position GPS d'étude doit être capturée avant transmission."], 422);
                return;
            }
            $availablePorts = RaccordementOtbService::getAvailablePorts($this->pdo, $selectedOtbId, $existingId > 0 ? $existingId : null);
            $selectedPort = null;
            foreach ($availablePorts as $port) {
                if ((int)($port['number'] ?? 0) === $selectedOtbPortNumber) { $selectedPort = $port; break; }
            }
            if (!$selectedPort || empty($selectedPort['available'])) {
                $this->jsonOk(['ok' => false, 'error' => 'validation_failed', 'message' => "Le port sélectionné n'est plus disponible."], 422);
                return;
            }
            $handoffTs = $now->format('Y-m-d H:i:s');
            $workflowPhase = 'installation';
            $studyTechnicianId = (int)($user['id'] ?? 0);
            $installationTechnicianId = 0; // Sera assigné par le superviseur ultérieurement
            $studySubmittedAt = $handoffTs;
            $installationSentAt = null;
            $selectedOtbPortStatus = 'reserved';
            // 'soumis' pour que apiValidationsList le remonte au superviseur
            $statusNew = 'soumis';
        }

        $satAllowed = ['très_satisfait','satisfait','peu_satisfait','non_satisfait'];
        $satService  = in_array($satService, $satAllowed, true) ? $satService : null;
        $satDelay    = in_array($satDelay, $satAllowed, true) ? $satDelay : null;
        $satQuality  = in_array($satQuality, $satAllowed, true) ? $satQuality : null;
        $satBehavior = in_array($satBehavior, $satAllowed, true) ? $satBehavior : null;
        $otbRecommendationPayload = null;
        if ($isRaccordementTicket) {
            $otbRecommendationPayload = $_POST['otb_recommendation_payload'] ?? null;
            if (is_string($otbRecommendationPayload)) {
                $trimmedPayload = trim($otbRecommendationPayload);
                if ($trimmedPayload === '') {
                    $otbRecommendationPayload = null;
                } else {
                    $decodedPayload = json_decode($trimmedPayload, true);
                    $otbRecommendationPayload = json_last_error() === JSON_ERROR_NONE ? $decodedPayload : $trimmedPayload;
                }
            }
        }
        $otbRecommendationJson = $otbRecommendationPayload !== null
            ? json_encode($otbRecommendationPayload, JSON_UNESCAPED_UNICODE)
            : null;

        if ($existingId > 0) {
            $upd = $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports SET
                    operation_type=?, intervention_date=?, intervention_start=?, intervention_end=?,
                    constat_comments=?, impacted_equipment=?,
                    materials_used=?, selected_otb_id=?, selected_otb_port_number=?, selected_otb_port_status=?,
                    selected_otb_label=?, recommended_otb_id=?, recommended_otb_label=?, otb_distance_meters=?,
                    otb_selection_mode=?, otb_recommendation_payload=?, general_comments=?,
                    satisfaction_service=?, satisfaction_delay=?, satisfaction_quality=?, satisfaction_behavior=?,
                    client_name_onsite=?, client_visit_status=?, planned_intervention_date=?,
                    planned_intervention_time=?, planning_comment=?, client_remarks=?, service_restored=?,
                    submit_gps_lat=?, submit_gps_lng=?, submit_gps_accuracy_meters=?,
                    study_gps_lat=?, study_gps_lng=?, study_gps_accuracy_meters=?,
                    workflow_phase=?, study_technician_id=?, installation_technician_id=?,
                    study_submitted_at=?, installation_sent_at=?,
                    status=?,
                    submitted_at=IF(?,NOW(),submitted_at),
                    updated_at=NOW()
                 WHERE id=?'
            );
            $upd->execute([
                $opType, $intervDate ?: null, $intervStart ?: null, $intervEnd ?: null,
                $constatComments, json_encode($impactedEq, JSON_UNESCAPED_UNICODE),
                json_encode($materialsUsed, JSON_UNESCAPED_UNICODE),
                $selectedOtbId > 0 ? $selectedOtbId : null,
                $selectedOtbPortNumber > 0 ? $selectedOtbPortNumber : null,
                $selectedOtbPortStatus,
                $selectedOtbLabel,
                $recommendedOtbId > 0 ? $recommendedOtbId : null,
                $recommendedOtbLabel,
                $otbDistanceMeters,
                $otbSelectionMode,
                $otbRecommendationJson,
                $generalComments,
                $satService, $satDelay, $satQuality, $satBehavior,
                $clientNameOS, $clientVisitStatus, $plannedInterventionDate, $plannedInterventionTime, $planningComment,
                $clientRemarks, $serviceRestored,
                $gpsLat ?: null, $gpsLng ?: null, $gpsAccuracyMeters,
                $studyGpsLat, $studyGpsLng, $studyGpsAccuracyMeters,
                $workflowPhase,
                $studyTechnicianId > 0 ? $studyTechnicianId : null,
                $installationTechnicianId > 0 ? $installationTechnicianId : null,
                $studySubmittedAt,
                $installationSentAt,
                $statusNew,
                $workflow['mark_submitted'] ? 1 : 0,
                $existingId,
            ]);
            if ($clientSigPath) {
                $this->pdo->prepare('UPDATE maintenance_ftth_reports SET client_signature_path=? WHERE id=?')
                    ->execute([$clientSigPath, $existingId]);
            }
            if ($agentSigPath) {
                $this->pdo->prepare('UPDATE maintenance_ftth_reports SET agent_signature_path=? WHERE id=?')
                    ->execute([$agentSigPath, $existingId]);
            }
            $reportId = $existingId;
        } else {
            $ins = $this->pdo->prepare(
                'INSERT INTO maintenance_ftth_reports
                 (ticket_id, technician_id, operation_type,
                  intervention_date, intervention_start, intervention_end,
                  constat_comments, impacted_equipment,
                  materials_used, selected_otb_id, selected_otb_port_number, selected_otb_port_status,
                  selected_otb_label, recommended_otb_id, recommended_otb_label, otb_distance_meters,
                  otb_selection_mode, otb_recommendation_payload, general_comments,
                  satisfaction_service, satisfaction_delay, satisfaction_quality, satisfaction_behavior,
                  client_name_onsite, client_visit_status, planned_intervention_date, planned_intervention_time,
                  planning_comment, client_remarks, service_restored,
                  client_signature_path, agent_signature_path,
                  submit_gps_lat, submit_gps_lng, submit_gps_accuracy_meters,
                  study_gps_lat, study_gps_lng, study_gps_accuracy_meters,
                  workflow_phase, study_technician_id, installation_technician_id,
                  study_submitted_at, installation_sent_at,
                  status, submitted_at)
                 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
            );
            $ins->execute([
                $ticketId, (int)$user['id'], $opType,
                $intervDate ?: null, $intervStart ?: null, $intervEnd ?: null,
                $constatComments, json_encode($impactedEq, JSON_UNESCAPED_UNICODE),
                json_encode($materialsUsed, JSON_UNESCAPED_UNICODE),
                $selectedOtbId > 0 ? $selectedOtbId : null,
                $selectedOtbPortNumber > 0 ? $selectedOtbPortNumber : null,
                $selectedOtbPortStatus,
                $selectedOtbLabel,
                $recommendedOtbId > 0 ? $recommendedOtbId : null,
                $recommendedOtbLabel,
                $otbDistanceMeters,
                $otbSelectionMode,
                $otbRecommendationJson,
                $generalComments,
                $satService, $satDelay, $satQuality, $satBehavior,
                $clientNameOS, $clientVisitStatus, $plannedInterventionDate, $plannedInterventionTime,
                $planningComment, $clientRemarks, $serviceRestored,
                $clientSigPath, $agentSigPath,
                $gpsLat ?: null, $gpsLng ?: null, $gpsAccuracyMeters,
                $studyGpsLat, $studyGpsLng, $studyGpsAccuracyMeters,
                $workflowPhase,
                $studyTechnicianId > 0 ? $studyTechnicianId : null,
                $installationTechnicianId > 0 ? $installationTechnicianId : null,
                $studySubmittedAt,
                $installationSentAt,
                $statusNew, $workflow['mark_submitted'] ? date('Y-m-d H:i:s') : null,
            ]);
            $reportId = (int)$this->pdo->lastInsertId();
        }

        // Sauvegarder les photos uploadées (constat + intervention)
        $persistedReportStatement = $this->pdo->prepare(
            'SELECT id, status, workflow_phase
             FROM maintenance_ftth_reports
             WHERE id = ? AND ticket_id = ?
             LIMIT 1'
        );
        $persistedReportStatement->execute([$reportId, $ticketId]);
        $persistedReport = $persistedReportStatement->fetch(PDO::FETCH_ASSOC) ?: null;
        if (!$persistedReport) {
            error_log('[maintenance:webReportSave] Report persistence failed for ticket ' . $ticketId . ' after save');
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('La fiche n\'a pas pu être enregistrée.'));
            return;
        }

        $this->saveReportPhotos($reportId, $ticketId, $destDir);

        // Mise à jour du statut du ticket
        if (in_array($clientVisitStatus, ['client_indisponible', 'reprogrammation_demandee'], true)) {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
                ->execute([$workflow['ticket_status'], $ticketId]);

            $this->notifyAssignmentOwnersForPlanningUpdate(
                $ticketId,
                $user,
                $clientVisitStatus,
                $plannedInterventionDate,
                $plannedInterventionTime,
                $planningComment
            );
        } elseif ($actionType === 'handoff' && $isRaccordementTicket) {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
                ->execute(['en_cours', $ticketId]);

            $tkSt2 = $this->pdo->prepare('SELECT client_name, ref_code FROM maintenance_ftth_tickets WHERE id=?');
            $tkSt2->execute([$ticketId]);
            $tkRow   = $tkSt2->fetch(\PDO::FETCH_ASSOC) ?: [];
            $cName   = trim((string)($tkRow['client_name'] ?? 'client'));
            $refCode = trim((string)($tkRow['ref_code'] ?? ('Ticket #' . $ticketId)));

            // Notifier TOUS les superviseurs étude & raccordement (résolution après sauvegarde DB)
            $terrainTechnicianIds = $this->resolveRaccordementHandoffTechnicianIds($ticket, $ticketId, (int)($user['id'] ?? 0));
            if (!empty($terrainTechnicianIds)) {
            Notifier::notifyUsers($this->pdo, array_map('intval', $terrainTechnicianIds),
                '[Raccordement] Dossier étude soumis pour validation',
                $user['name'] . ' vous a soumis le dossier étude de ' . $cName . ' (' . $refCode . '). Merci de valider et d\'assigner un technicien raccordement.',
                '/raccordement-clients/' . $ticketId
            );
            Notifier::pushUsers($this->pdo, array_map('intval', $terrainTechnicianIds),
                '[Raccordement] Dossier à valider',
                $user['name'] . ' vous a soumis un dossier raccordement pour validation.',
                '/raccordement-clients/' . $ticketId
            );
            }
        } elseif ($actionType === 'submit') {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')
                ->execute(['traité', $ticketId]);

            $supervisors = $this->resolveValidationRecipientIds($ticketId);

            if (!empty($supervisors)) {
                $tkSt = $this->pdo->prepare('SELECT client_name FROM maintenance_ftth_tickets WHERE id=?');
                $tkSt->execute([$ticketId]);
                $clientName = $tkSt->fetchColumn();

                $notifModule = $isRaccordementTicket ? '/raccordement-clients/' : '/maintenance-ftth/';
                $notifTitle  = $isRaccordementTicket ? '[Raccordement] Rapport installation soumis' : '[FTTH] Rapport soumis';
                $notifBody   = $isRaccordementTicket
                    ? ($user['name'] . ' a soumis le rapport installation pour le client ' . $clientName . '. En attente de validation.')
                    : ($user['name'] . ' a soumis le rapport pour le client ' . $clientName . '. En attente de validation.');
                $notifPushBody = $isRaccordementTicket
                    ? ($user['name'] . ' a soumis un rapport raccordement installation.')
                    : ($user['name'] . ' a soumis un rapport terrain.');

                Notifier::notifyUsers(
                    $this->pdo,
                    array_map('intval', $supervisors),
                    $notifTitle,
                    $notifBody,
                    $notifModule . $ticketId
                );
                Notifier::pushUsers(
                    $this->pdo,
                    array_map('intval', $supervisors),
                    $notifTitle,
                    $notifPushBody,
                    $notifModule . $ticketId
                );
            }
        }

        $this->jsonOk([
            'ok'               => true,
            'report_id'        => $reportId,
            'status'           => $statusNew,
            'ticket_status'    => $workflow['ticket_status'],
            'client_visit_status' => $clientVisitStatus,
            'workflow_phase'   => $workflowPhase,
        ]);

        } catch (\Throwable $e) {
            error_log('[apiReportSave] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            $this->jsonOk(['ok' => false, 'error' => 'server_error', 'message' => $e->getMessage()], 500);
        }
    }

    protected function saveReportPhotos(int $reportId, int $ticketId, string $destDir): void
    {
        // Photos du constat (constat_photos[])
        if (!empty($_FILES['constat_photos']['name'])) {
            $files = $_FILES['constat_photos'];
            $count = is_array($files['name']) ? count($files['name']) : 1;
            for ($i = 0; $i < $count; $i++) {
                $tmpName = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'];
                $origName = is_array($files['name']) ? $files['name'][$i] : $files['name'];
                if (!$tmpName || !is_uploaded_file($tmpName)) continue;
                $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
                if (!in_array($ext, ['jpg','jpeg','png','gif'], true)) continue;
                $filename = 'constat_' . $i . '_' . uniqid() . '.' . $ext;
                move_uploaded_file($tmpName, $destDir . $filename);
                $comment = is_array($_POST['constat_photo_comments'] ?? [])
                    ? (($_POST['constat_photo_comments'][$i] ?? ''))
                    : '';
                $lat = is_array($_POST['constat_photo_lats'] ?? []) ? ($_POST['constat_photo_lats'][$i] ?? null) : null;
                $lng = is_array($_POST['constat_photo_lngs'] ?? []) ? ($_POST['constat_photo_lngs'][$i] ?? null) : null;
                $this->pdo->prepare(
                    'INSERT INTO maintenance_ftth_photos (report_id, tab_type, item_index, photo_path, comment, gps_lat, gps_lng)
                     VALUES (?,?,?,?,?,?,?)'
                )->execute([$reportId, 'constat', $i, $filename, $comment, $lat ?: null, $lng ?: null]);
            }
        }

        // Photos d'intervention (interv_photos[])
        if (!empty($_FILES['interv_photos']['name'])) {
            $files = $_FILES['interv_photos'];
            $count = is_array($files['name']) ? count($files['name']) : 1;
            for ($i = 0; $i < $count; $i++) {
                $tmpName  = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'];
                $origName = is_array($files['name']) ? $files['name'][$i] : $files['name'];
                if (!$tmpName || !is_uploaded_file($tmpName)) continue;
                $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
                if (!in_array($ext, ['jpg','jpeg','png','gif'], true)) continue;
                $filename = 'interv_' . $i . '_' . uniqid() . '.' . $ext;
                move_uploaded_file($tmpName, $destDir . $filename);
                $itemIdx = is_array($_POST['interv_photo_item_index'] ?? []) ? (int)($_POST['interv_photo_item_index'][$i] ?? 0) : 0;
                $comment = is_array($_POST['interv_photo_comments'] ?? []) ? ($_POST['interv_photo_comments'][$i] ?? '') : '';
                $this->pdo->prepare(
                    'INSERT INTO maintenance_ftth_photos (report_id, tab_type, item_index, photo_path, comment)
                     VALUES (?,?,?,?,?)'
                )->execute([$reportId, 'intervention', $itemIdx, $filename, $comment]);
            }
        }
    }

    // ================================================================
    //  MOBILE API — GET /api/mobile/ftth/validations (superviseur)
    // ================================================================
    public function apiValidationsList(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }
        $roleKey = strtolower(trim((string)($user['role_key'] ?? '')));
        if (!in_array($roleKey, ['admin', 'manager', 'superviseur', 'supervisor'], true)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        $where = ["r.status = 'soumis'"];
        $params = [];
        // Superviseur : restreindre aux tickets de son module
        if (in_array($roleKey, ['superviseur', 'supervisor'], true)) {
            $mobileType = $this->resolveMobileTechnicianType($user);
            if ($mobileType === 'raccordement') {
                $where[] = $this->maintenanceModuleWhereClause(true, 't');
            } elseif ($mobileType === 'maintenance_ftth') {
                $where[] = $this->maintenanceModuleWhereClause(false, 't');
            }
        }
        $this->appendAssignedCityFilter($where, $params, $user, 't');

        $st = $this->pdo->prepare(
            "SELECT r.id AS report_id, r.ticket_id, r.status AS report_status,
                    r.submitted_at, r.study_submitted_at, r.operation_type, r.intervention_date,
                    r.workflow_phase, r.installation_technician_id,
                    t.client_name, t.client_address, t.nature_intervention, t.priority,
                    t.ref_code, t.extra_fields,
                    u.name AS technician_name
             FROM maintenance_ftth_reports r
             JOIN maintenance_ftth_tickets t ON t.id = r.ticket_id
             JOIN users u ON u.id = r.technician_id
             WHERE " . implode(' AND ', $where) . "
             ORDER BY COALESCE(r.study_submitted_at, r.submitted_at) ASC"
        );
        $st->execute($params);
        $items = $st->fetchAll(PDO::FETCH_ASSOC);
        $this->jsonOk(['ok' => true, 'items' => $items]);
    }

    // ================================================================
    //  MOBILE API — POST /api/mobile/ftth/validate
    // ================================================================
    public function apiValidate(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }
        $roleKey = strtolower(trim((string)($user['role_key'] ?? '')));
        if (!in_array($roleKey, ['admin', 'manager', 'superviseur', 'supervisor'], true)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        $reportId              = (int)($_POST['report_id'] ?? 0);
        $action                = $_POST['action'] ?? '';
        $reason                = trim($_POST['reason'] ?? '');
        $installationTechId    = (int)($_POST['installation_technician_id'] ?? 0);

        $repSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_reports WHERE id=? LIMIT 1');
        $repSt->execute([$reportId]);
        $report = $repSt->fetch(PDO::FETCH_ASSOC);
        if (!$report) {
            $this->jsonOk(['ok' => false, 'error' => 'not_found'], 404);
            return;
        }

        if (!$this->canAccessMaintenanceTicket((int)$report['ticket_id'], $user, 'view')) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        $ticketId      = (int)$report['ticket_id'];
        $workflowPhase = (string)($report['workflow_phase'] ?? 'installation');
        $isRaccHandoff = ($workflowPhase === 'installation') && ($report['study_submitted_at'] ?? null) !== null;

        if ($action === 'validate') {
            if ($isRaccHandoff) {
                // Validation dossier étude raccordement : on reset pour la phase installation
                if ($installationTechId <= 0) {
                    $this->jsonOk(['ok' => false, 'error' => 'missing_installation_tech', 'message' => 'Sélectionnez un technicien raccordement avant de valider.'], 422);
                    return;
                }
                $this->pdo->prepare(
                    'UPDATE maintenance_ftth_reports SET installation_technician_id=?, status=\'brouillon\', validated_at=NOW(), validated_by=? WHERE id=?'
                )->execute([$installationTechId, (int)$user['id'], $reportId]);
                $this->removeAssignmentsFromTicket($ticketId, null);
                $this->assignTechniciansToTicket($ticketId, [$installationTechId], (int)$user['id'], '');
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=\'assigné\' WHERE id=?')
                    ->execute([$ticketId]);

                $tkSt = $this->pdo->prepare('SELECT client_name, ref_code FROM maintenance_ftth_tickets WHERE id=?');
                $tkSt->execute([$ticketId]);
                $tkRow   = $tkSt->fetch(\PDO::FETCH_ASSOC) ?: [];
                $cName   = trim((string)($tkRow['client_name'] ?? 'client'));
                $refCode = trim((string)($tkRow['ref_code'] ?? ('Ticket #' . $ticketId)));
                Notifier::notifyUsers($this->pdo, [$installationTechId],
                    '[Raccordement] Dossier installation assigné',
                    $user['name'] . ' vous a assigné le dossier installation de ' . $cName . ' (' . $refCode . ').',
                    '/raccordement-clients/' . $ticketId
                );
                Notifier::pushUsers($this->pdo, [$installationTechId],
                    '[Raccordement] Dossier assigné',
                    'Un dossier raccordement installation vous a été assigné.',
                    '/raccordement-clients/' . $ticketId
                );
                // Notifier aussi le technicien étude que l'étude a été validée
                $studyTechId = (int)($report['technician_id'] ?? 0);
                if ($studyTechId > 0) {
                    Notifier::notifyUsers($this->pdo, [$studyTechId],
                        '[Raccordement] Étude validée',
                        'Votre dossier étude pour ' . $cName . ' a été validé. Un technicien raccordement a été assigné.',
                        '/raccordement-clients/' . $ticketId
                    );
                }
            } else {
                // Validation rapport standard (maintenance ou installation raccordement)
                $this->pdo->prepare(
                    'UPDATE maintenance_ftth_reports SET status=\'validé\', validated_at=NOW(), validated_by=? WHERE id=?'
                )->execute([(int)$user['id'], $reportId]);
                RaccordementOtbService::syncOccupancyFromReport($this->pdo, $reportId);
                $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=\'validé\' WHERE id=?')
                    ->execute([$ticketId]);

                try {
                    $publicClientSt = $this->pdo->prepare('SELECT public_client_id FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
                    $publicClientSt->execute([$ticketId]);
                    $publicClientId = (int)($publicClientSt->fetchColumn() ?: 0);
                    if ($publicClientId > 0) {
                        $this->pdo->prepare("UPDATE public_mobile_incidents SET status='resolu', updated_at=NOW() WHERE ticket_id=? AND status <> 'ferme'")
                            ->execute([$ticketId]);
                        Notifier::notifyPublicClients($this->pdo, [$publicClientId], '[e-Intervention] Incident résolu', 'Votre demande liée à l\'intervention FTTH a été validée comme résolue.', '#/history');
                    }
                } catch (\Throwable $e) {
                }
                Notifier::notifyUsers($this->pdo, [(int)$report['technician_id']],
                    '[FTTH] Rapport validé', 'Votre rapport a été validé.',
                    '/maintenance-ftth/detail?id=' . $ticketId
                );
            }
        } elseif ($action === 'reject') {
            $this->pdo->prepare(
                'UPDATE maintenance_ftth_reports SET status=\'rejeté\', validated_at=NULL, rejection_reason=? WHERE id=?'
            )->execute([$reason, $reportId]);
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=\'en_cours\' WHERE id=?')
                ->execute([$ticketId]);
            Notifier::notifyUsers($this->pdo, [(int)$report['technician_id']],
                '[FTTH] Rapport rejeté', 'Votre rapport a été rejeté. Motif : ' . $reason,
                '/maintenance-ftth/detail?id=' . $ticketId
            );
        } else {
            $this->jsonOk(['ok' => false, 'error' => 'invalid_action'], 400);
            return;
        }

        $this->jsonOk(['ok' => true, 'action' => $action, 'is_racc_handoff' => $isRaccHandoff]);
    }

    // ================================================================
    //  MOBILE API — GET /api/mobile/ftth/raccordement-techs
    // ================================================================
    public function apiMobileRaccordementTechs(): void
    {
        $user = $this->findMobileUser();
        if (!$user) {
            $this->jsonOk(['ok' => false, 'error' => 'unauthorized'], 401);
            return;
        }
        $roleKey = strtolower(trim((string)($user['role_key'] ?? '')));
        if (!in_array($roleKey, ['admin', 'manager', 'superviseur', 'supervisor'], true)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        $where = [
            "active = 1",
            "technician_type IN ('raccordement', 'raccordement_clients', 'etude_raccordement')",
        ];
        $params = [];
        $assignedCity = $this->normalizeAssignedCityValue((string)($user['assigned_city'] ?? ''));
        if ($assignedCity !== null) {
            $where[] = "(assigned_city IS NULL OR assigned_city = '' OR assigned_city = ?)";
            $params[] = $assignedCity;
        }

        $st = $this->pdo->prepare(
            "SELECT id, name, technician_type FROM users WHERE " . implode(' AND ', $where) . " ORDER BY name ASC"
        );
        $st->execute($params);
        $techs = $st->fetchAll(\PDO::FETCH_ASSOC);
        $this->jsonOk(['ok' => true, 'techs' => $techs]);
    }

    public function webReportAutosave(): void
    {
        $u = $this->requireAuth();

        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
            $this->jsonOk(['ok' => false, 'error' => 'method_not_allowed'], 405);
            return;
        }

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        if ($ticketId <= 0) {
            $this->jsonOk(['ok' => false, 'error' => 'missing_ticket_id'], 400);
            return;
        }

        if (!$this->canEditWebReportForTicket($ticketId, $u)) {
            $this->jsonOk(['ok' => false, 'error' => 'forbidden'], 403);
            return;
        }

        $reportId = $this->ensureWebReportDraftExists($ticketId, (int)$u['id']);
        $impactedEq = $this->sanitizeReportNamedItems(json_decode($_POST['impacted_equipment'] ?? '[]', true) ?: []);
        $materialsUsed = $this->sanitizeReportNamedItems(json_decode($_POST['materials_used'] ?? '[]', true) ?: [], true);

        $this->pdo->prepare(
            'UPDATE maintenance_ftth_reports SET impacted_equipment=?, materials_used=?, updated_at=NOW() WHERE id=?'
        )->execute([
            json_encode($impactedEq, JSON_UNESCAPED_UNICODE),
            json_encode($materialsUsed, JSON_UNESCAPED_UNICODE),
            $reportId,
        ]);

        $this->jsonOk([
            'ok' => true,
            'report_id' => $reportId,
            'saved_at' => date('H:i:s'),
            'impacted_equipment' => $impactedEq,
            'materials_used' => $materialsUsed,
        ]);
    }

    public function reportSuggestions(): void
    {
        $this->requireAuth();

        $kind = trim((string)($_GET['kind'] ?? ''));
        $query = trim((string)($_GET['q'] ?? ''));
        if (!in_array($kind, ['equipment', 'material'], true)) {
            $this->jsonOk(['ok' => false, 'error' => 'invalid_kind'], 400);
            return;
        }

        $this->jsonOk([
            'ok' => true,
            'items' => $this->fetchReportItemSuggestions($kind, $query),
        ]);
    }

    // ================================================================
    // WEB — FICHE TERRAIN (technicien web)
    // ================================================================
    public function webReportSave(): void
    {
        $u = $this->requireAuth();
        $isRaccordementClients = $this->isRaccordementClientsRequest();
        $moduleBasePath = $this->currentModuleBasePath();

        $ticketId = (int)($_POST['ticket_id'] ?? 0);
        $actionType = in_array($_POST['action_type'] ?? '', ['draft','submit','handoff']) ? $_POST['action_type'] : 'draft';

        if ($ticketId <= 0) {
            $this->redirect($moduleBasePath);
            return;
        }

        if (!$this->canAccessMaintenanceTicket($ticketId, $u, 'view')) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=forbidden');
            return;
        }

        $isAdmin = in_array($u['role_key'] ?? '', ['admin','agent','manager','superviseur','supervisor'], true);
        if (!$isAdmin) {
            $chk = $this->pdo->prepare('SELECT 1 FROM maintenance_ftth_assignments WHERE ticket_id=? AND technician_id=? LIMIT 1');
            $chk->execute([$ticketId, (int)$u['id']]);
            if (!$chk->fetchColumn()) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=forbidden');
                return;
            }
        }

        $ticketSt = $this->pdo->prepare('SELECT * FROM maintenance_ftth_tickets WHERE id=? LIMIT 1');
        $ticketSt->execute([$ticketId]);
        $ticket = $ticketSt->fetch(PDO::FETCH_ASSOC) ?: null;
        if (!$ticket) {
            $this->redirect($moduleBasePath . '?err=' . urlencode('Dossier introuvable.'));
            return;
        }

        $existing = $this->pdo->prepare(
            'SELECT id, intervention_date, intervention_start, intervention_end, workflow_phase, selected_otb_id, selected_otb_port_number, selected_otb_port_status, study_technician_id, installation_technician_id, study_submitted_at, installation_sent_at
             FROM maintenance_ftth_reports WHERE ticket_id=? LIMIT 1'
        );
        $existing->execute([$ticketId]);
        $existingReport = $existing->fetch(PDO::FETCH_ASSOC) ?: null;

        if ($isRaccordementClients && $actionType === 'handoff' && !$this->canEditRaccordementStudyPhase($u, $existingReport)) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Seul le technicien étude peut transmettre ce dossier.'));
            return;
        }

        if ($isRaccordementClients && (($existingReport['workflow_phase'] ?? 'study') !== 'study') && $actionType !== 'handoff' && !$this->canEditRaccordementInstallationPhase($u, $existingReport)) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Cette phase est réservée au technicien terrain en charge du raccordement.'));
            return;
        }

        $destDir = $this->uploadDir($ticketId);
        $opType = in_array($_POST['operation_type'] ?? '', ['maintenance','travaux','autres']) ? $_POST['operation_type'] : 'maintenance';
        $intervDate = $_POST['intervention_date'] ?? null;
        $intervStart = $_POST['intervention_start'] ?? null;
        $intervEnd = $_POST['intervention_end'] ?? null;

        $constatComments = trim($_POST['constat_comments'] ?? '');
        $impactedEq = json_decode($_POST['impacted_equipment'] ?? '[]', true) ?: [];
        $materialsUsed = json_decode($_POST['materials_used'] ?? '[]', true) ?: [];
        $generalComments = trim($_POST['general_comments'] ?? '');
        $selectedOtbId = (int)($_POST['selected_otb_id'] ?? ($existingReport['selected_otb_id'] ?? 0));
        $selectedOtbPortNumber = (int)($_POST['selected_otb_port_number'] ?? ($existingReport['selected_otb_port_number'] ?? 0));
        $recommendedOtbId = (int)($_POST['recommended_otb_id'] ?? 0);
        $otbDistanceMeters = is_numeric($_POST['otb_distance_meters'] ?? null) ? round((float)$_POST['otb_distance_meters'], 2) : null;
        $otbSelectionMode = trim((string)($_POST['otb_selection_mode'] ?? ($selectedOtbId > 0 ? 'manual' : 'none')));
        if (!in_array($otbSelectionMode, ['auto', 'manual', 'none'], true)) {
            $otbSelectionMode = 'none';
        }
        if (!$isRaccordementClients) {
            $selectedOtbId = 0;
            $selectedOtbPortNumber = 0;
            $recommendedOtbId = 0;
            $otbDistanceMeters = null;
            $otbSelectionMode = 'none';
        }
        $selectedOtb = $selectedOtbId > 0 ? RaccordementOtbService::findBoxById($this->pdo, $selectedOtbId) : null;
        $recommendedOtb = $recommendedOtbId > 0 ? RaccordementOtbService::findBoxById($this->pdo, $recommendedOtbId) : null;
        $selectedOtbLabel = $selectedOtb['box_code'] ?? null;
        $recommendedOtbLabel = $recommendedOtb['box_code'] ?? null;

        $satAllowed = ['très_satisfait','satisfait','peu_satisfait','non_satisfait'];
        $satService = in_array($_POST['satisfaction_service'] ?? '', $satAllowed, true) ? $_POST['satisfaction_service'] : null;
        $satDelay = in_array($_POST['satisfaction_delay'] ?? '', $satAllowed, true) ? $_POST['satisfaction_delay'] : null;
        $satQuality = in_array($_POST['satisfaction_quality'] ?? '', $satAllowed, true) ? $_POST['satisfaction_quality'] : null;
        $satBehavior = in_array($_POST['satisfaction_behavior'] ?? '', $satAllowed, true) ? $_POST['satisfaction_behavior'] : null;

        $clientNameOS = trim($_POST['client_name_onsite'] ?? '');
        $clientVisitStatus = $this->normalizeClientVisitStatus($_POST['client_visit_status'] ?? null);
        $plannedInterventionDate = $this->normalizeOptionalDateValue($_POST['planned_intervention_date'] ?? null);
        $plannedInterventionTime = $this->normalizeOptionalTimeValue($_POST['planned_intervention_time'] ?? null);
        $planningComment = trim((string)($_POST['planning_comment'] ?? ''));
        $clientRemarks = trim($_POST['client_remarks'] ?? '');
        $serviceRestored = (int)($_POST['service_restored'] ?? 0);

        if ($clientVisitStatus === 'reprogrammation_demandee' && (!$plannedInterventionDate || !$plannedInterventionTime)) {
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Veuillez renseigner la date et l\'heure de replanification.'));
            return;
        }

        $clientSigPath = null;
        $agentSigPath = null;
        if (!empty($_POST['client_signature_data'])) {
            $clientSigPath = $this->saveBase64Image($_POST['client_signature_data'], $destDir, 'sig_client');
        }
        if (!empty($_POST['agent_signature_data'])) {
            $agentSigPath = $this->saveBase64Image($_POST['agent_signature_data'], $destDir, 'sig_agent');
        }

        $gpsLat = $_POST['gps_lat'] ?? null;
        $gpsLng = $_POST['gps_lng'] ?? null;
        $gpsAccuracyMeters = is_numeric($_POST['gps_accuracy_meters'] ?? null) ? round((float)$_POST['gps_accuracy_meters'], 2) : null;
        $studyGpsLat = is_numeric($_POST['study_gps_lat'] ?? null) ? (float)$_POST['study_gps_lat'] : null;
        $studyGpsLng = is_numeric($_POST['study_gps_lng'] ?? null) ? (float)$_POST['study_gps_lng'] : null;
        $studyGpsAccuracyMeters = is_numeric($_POST['study_gps_accuracy_meters'] ?? null) ? round((float)$_POST['study_gps_accuracy_meters'], 2) : null;

        $workflow = $this->resolveFtthWorkflow($clientVisitStatus, $actionType);
        $statusNew = $workflow['report_status'];
        $existingId = (int)($existingReport['id'] ?? 0);

        $now = new \DateTimeImmutable('now');
        $intervDate = trim((string)$intervDate) !== '' ? $intervDate : ($existingReport['intervention_date'] ?? $now->format('Y-m-d'));
        $intervStart = trim((string)$intervStart) !== '' ? $intervStart : ($existingReport['intervention_start'] ?? $now->format('H:i:s'));
        if ($actionType === 'submit') {
            $intervEnd = $now->format('H:i:s');
        } elseif (trim((string)$intervEnd) === '' && !empty($existingReport['intervention_end'])) {
            $intervEnd = $existingReport['intervention_end'];
        }

        $workflowPhase = $isRaccordementClients ? (string)($existingReport['workflow_phase'] ?? 'study') : 'installation';
        $studyTechnicianId = (int)($existingReport['study_technician_id'] ?? 0);
        $installationTechnicianId = (int)($existingReport['installation_technician_id'] ?? 0);
        $studySubmittedAt = $existingReport['study_submitted_at'] ?? null;
        $installationSentAt = $existingReport['installation_sent_at'] ?? null;
        $selectedOtbPortStatus = (string)($existingReport['selected_otb_port_status'] ?? 'none');
        $requestedHandoffTechnicianId = (int)($_POST['handoff_technician_id'] ?? 0);

        if ($isRaccordementClients && $actionType === 'handoff') {
            if ($selectedOtbId <= 0 || !$selectedOtb) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Sélectionnez un boîtier intelligent avant transmission.'));
                return;
            }
            if ($selectedOtbPortNumber <= 0) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Réservez un port actif avant transmission.'));
                return;
            }
            if (!$this->hasValidCoordinates($studyGpsLat, $studyGpsLng)) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('La position étude doit être capturée avant transmission.'));
                return;
            }

            $availablePorts = RaccordementOtbService::getAvailablePorts($this->pdo, $selectedOtbId, $existingId > 0 ? $existingId : null);
            $selectedPort = null;
            foreach ($availablePorts as $port) {
                if ((int)($port['number'] ?? 0) === $selectedOtbPortNumber) {
                    $selectedPort = $port;
                    break;
                }
            }
            if (!$selectedPort || empty($selectedPort['available'])) {
                $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&err=' . urlencode('Le port sélectionné n\'est plus disponible.'));
                return;
            }

            $terrainTechnicianIds = $this->resolveRaccordementHandoffTechnicianIds($ticket, $ticketId, (int)($u['id'] ?? 0));

            $workflowPhase = 'installation';
            $studyTechnicianId = (int)($u['id'] ?? 0);
            $installationTechnicianId = 0; // Sera assigné par le superviseur ultérieurement
            $studySubmittedAt = $now->format('Y-m-d H:i:s');
            $installationSentAt = $studySubmittedAt;
            $selectedOtbPortStatus = 'reserved';
            $statusNew = 'brouillon';
        }

        if ($existingId <= 0) {
            $existingId = $this->ensureWebReportDraftExists($ticketId, (int)($u['id'] ?? 0));
        }

        $this->pdo->prepare(
            'UPDATE maintenance_ftth_reports SET
                operation_type=?, intervention_date=?, intervention_start=?, intervention_end=?,
                constat_comments=?, impacted_equipment=?,
                materials_used=?, selected_otb_id=?, selected_otb_port_number=?, selected_otb_port_status=?, selected_otb_label=?, recommended_otb_id=?, recommended_otb_label=?, otb_distance_meters=?, otb_selection_mode=?, general_comments=?,
                satisfaction_service=?, satisfaction_delay=?, satisfaction_quality=?, satisfaction_behavior=?,
                client_name_onsite=?, client_visit_status=?, planned_intervention_date=?, planned_intervention_time=?, planning_comment=?, client_remarks=?, service_restored=?,
                submit_gps_lat=?, submit_gps_lng=?, submit_gps_accuracy_meters=?, status=?, workflow_phase=?, study_technician_id=?, installation_technician_id=?, study_submitted_at=?, installation_sent_at=?, study_gps_lat=?, study_gps_lng=?, study_gps_accuracy_meters=?,
                submitted_at=IF(?,NOW(),submitted_at), updated_at=NOW()
             WHERE id=?'
        )->execute([
            $opType, $intervDate ?: null, $intervStart ?: null, $intervEnd ?: null,
            $constatComments, json_encode($impactedEq, JSON_UNESCAPED_UNICODE),
            json_encode($materialsUsed, JSON_UNESCAPED_UNICODE),
            $selectedOtbId > 0 ? $selectedOtbId : null,
            $selectedOtbPortNumber > 0 ? $selectedOtbPortNumber : null,
            $selectedOtbPortStatus,
            $selectedOtbLabel,
            $recommendedOtbId > 0 ? $recommendedOtbId : null,
            $recommendedOtbLabel,
            $otbDistanceMeters,
            $otbSelectionMode,
            $generalComments,
            $satService, $satDelay, $satQuality, $satBehavior,
            $clientNameOS, $clientVisitStatus, $plannedInterventionDate, $plannedInterventionTime, $planningComment, $clientRemarks, $serviceRestored,
            $gpsLat ?: null, $gpsLng ?: null, $gpsAccuracyMeters, $statusNew, $workflowPhase, $studyTechnicianId > 0 ? $studyTechnicianId : null, $installationTechnicianId > 0 ? $installationTechnicianId : null, $studySubmittedAt, $installationSentAt, $studyGpsLat, $studyGpsLng, $studyGpsAccuracyMeters,
            $workflow['mark_submitted'] ? 1 : 0,
            $existingId,
        ]);
        if ($clientSigPath) {
            $this->pdo->prepare('UPDATE maintenance_ftth_reports SET client_signature_path=? WHERE id=?')->execute([$clientSigPath, $existingId]);
        }
        if ($agentSigPath) {
            $this->pdo->prepare('UPDATE maintenance_ftth_reports SET agent_signature_path=? WHERE id=?')->execute([$agentSigPath, $existingId]);
        }
        $reportId = $existingId;

        $this->saveReportPhotos($reportId, $ticketId, $destDir);

        if ($isRaccordementClients && $actionType === 'handoff') {
            $this->removeAssignmentsFromTicket($ticketId, null);
            $this->logTicketEvent(
                $ticketId,
                (int)($u['id'] ?? 0),
                'study_transmitted',
                'Étude transmise aux superviseurs raccordement',
                'Boîtier ' . ($selectedOtbLabel ?: ('#' . $selectedOtbId)) . ' · Port réservé ' . $selectedOtbPortNumber,
                'info',
                'share-nodes'
            );
            // Notifier TOUS les superviseurs étude & raccordement
            $tkSt3 = $this->pdo->prepare('SELECT client_name, ref_code FROM maintenance_ftth_tickets WHERE id=?');
            $tkSt3->execute([$ticketId]);
            $tkRow3   = $tkSt3->fetch(\PDO::FETCH_ASSOC) ?: [];
            $cName3   = trim((string)($tkRow3['client_name'] ?? 'client'));
            $refCode3 = trim((string)($tkRow3['ref_code'] ?? ('Ticket #' . $ticketId)));
            if (!empty($terrainTechnicianIds)) {
            Notifier::notifyUsers($this->pdo, array_map('intval', $terrainTechnicianIds),
                '[Raccordement] Dossier étude soumis pour validation',
                $u['name'] . ' vous a soumis le dossier étude de ' . $cName3 . ' (' . $refCode3 . '). Merci de valider et d\'assigner un technicien raccordement.',
                '/raccordement-clients/' . $ticketId
            );
            Notifier::pushUsers($this->pdo, array_map('intval', $terrainTechnicianIds),
                '[Raccordement] Dossier à valider',
                $u['name'] . ' vous a soumis un dossier raccordement pour validation.',
                '/raccordement-clients/' . $ticketId
            );
            }
            $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . '&transmitted=1');
            return;
        }

        if (in_array($clientVisitStatus, ['client_indisponible', 'reprogrammation_demandee'], true)) {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')->execute([$workflow['ticket_status'], $ticketId]);
            $this->notifyAssignmentOwnersForPlanningUpdate($ticketId, $u, $clientVisitStatus, $plannedInterventionDate, $plannedInterventionTime, $planningComment);
        } elseif ($actionType === 'submit') {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')->execute(['traité', $ticketId]);

            if ($isRaccordementClients && $selectedOtbId > 0) {
                RaccordementOtbService::recomputeBoxOccupancy($this->pdo, $selectedOtbId);
            }

            $supervisors = $this->pdo->query("SELECT id FROM users WHERE active=1 AND role_key IN ('admin','superviseur','supervisor','manager')")->fetchAll(PDO::FETCH_COLUMN);
            if (!empty($supervisors)) {
                $clientName = $this->pdo->prepare('SELECT client_name FROM maintenance_ftth_tickets WHERE id=?');
                $clientName->execute([$ticketId]);
                $cName = $clientName->fetchColumn();
                Notifier::notifyUsers(
                    $this->pdo,
                    array_map('intval', $supervisors),
                    $isRaccordementClients ? '[Raccordement] Rapport soumis' : '[FTTH] Rapport soumis',
                    $u['name'] . ' a soumis le rapport pour le client ' . $cName . '. En attente de validation.',
                    $this->detailPathForModule($ticketId, $moduleBasePath)
                );
            }
        } else {
            $this->pdo->prepare('UPDATE maintenance_ftth_tickets SET status=? WHERE id=?')->execute(['en_cours', $ticketId]);
        }

        $this->redirect($this->detailPathForModule($ticketId, $moduleBasePath) . ($actionType === 'submit' ? '&submitted=1' : '&saved=1'));
    }
}
