<?php
declare(strict_types=1);

namespace App\Services;

use App\Core\Audit\AuditLogger;
use App\Core\Database;
use PDO;
use RuntimeException;
use Throwable;

final class ReleaseTrainService
{
    private const TRAIN_STATUSES = ['draft', 'planned', 'active', 'completed', 'cancelled'];
    private const STEP_STATUSES = ['todo', 'in_progress', 'blocked', 'completed'];

    public function paginate(array $filters, int $organisationId): array
    {
        $pdo = Database::connection();
        $query = trim((string) ($filters['q'] ?? ''));
        $status = (string) ($filters['status'] ?? '');
        $page = max(1, (int) ($filters['page'] ?? 1));
        $perPage = 20;
        $allowedSorts = ['code', 'name', 'status', 'target_version', 'planned_at', 'updated_at'];
        $sort = in_array((string) ($filters['sort'] ?? ''), $allowedSorts, true)
            ? (string) $filters['sort']
            : 'updated_at';
        $direction = strtolower((string) ($filters['dir'] ?? '')) === 'asc' ? 'ASC' : 'DESC';

        $where = ['rt.organisation_id = :organisation_id', 'rt.archived_at IS NULL'];
        $parameters = ['organisation_id' => $organisationId];
        if ($query !== '') {
            $where[] = '(rt.code LIKE :query OR rt.name LIKE :query OR rt.description LIKE :query)';
            $parameters['query'] = '%' . $query . '%';
        }
        if (in_array($status, self::TRAIN_STATUSES, true)) {
            $where[] = 'rt.status = :status';
            $parameters['status'] = $status;
        }

        $whereSql = implode(' AND ', $where);
        $countStatement = $pdo->prepare('SELECT COUNT(*) FROM axiom_release_trains rt WHERE ' . $whereSql);
        $countStatement->execute($parameters);
        $total = (int) $countStatement->fetchColumn();
        $offset = ($page - 1) * $perPage;

        $listSql = 'SELECT rt.*, COUNT(st.id) AS step_count, '
            . "SUM(CASE WHEN st.status = 'completed' THEN 1 ELSE 0 END) AS completed_steps, "
            . "SUM(CASE WHEN st.status = 'blocked' THEN 1 ELSE 0 END) AS blocked_steps "
            . 'FROM axiom_release_trains rt '
            . 'LEFT JOIN axiom_release_train_steps st '
            . 'ON st.release_train_id = rt.id '
            . 'AND st.organisation_id = rt.organisation_id '
            . 'AND st.archived_at IS NULL '
            . 'WHERE ' . $whereSql . ' '
            . 'GROUP BY rt.id '
            . 'ORDER BY rt.' . $sort . ' ' . $direction . ', rt.id DESC '
            . 'LIMIT ' . $perPage . ' OFFSET ' . $offset;
        $listStatement = $pdo->prepare($listSql);
        $listStatement->execute($parameters);
        $rows = $listStatement->fetchAll(PDO::FETCH_ASSOC);
        foreach ($rows as &$row) {
            $count = (int) $row['step_count'];
            $row['progress'] = $count > 0
                ? (int) round(((int) $row['completed_steps'] / $count) * 100)
                : 0;
        }
        unset($row);

        return [
            'rows' => $rows,
            'total' => $total,
            'page' => $page,
            'pages' => max(1, (int) ceil($total / $perPage)),
            'per_page' => $perPage,
            'sort' => $sort,
            'dir' => strtolower($direction),
        ];
    }

    public function roadmap(int $organisationId): array
    {
        $statement = Database::connection()->prepare(
            'SELECT rt.id, rt.code, rt.name, rt.description, rt.target_version, rt.status, rt.planned_at, '
            . '(SELECT COUNT(*) FROM axiom_release_train_steps st '
            . 'WHERE st.release_train_id = rt.id AND st.organisation_id = rt.organisation_id '
            . 'AND st.archived_at IS NULL) AS step_count, '
            . "(SELECT COUNT(*) FROM axiom_release_train_steps st WHERE st.release_train_id = rt.id "
            . "AND st.organisation_id = rt.organisation_id AND st.archived_at IS NULL "
            . "AND st.status = 'completed') AS completed_steps, "
            . "(SELECT COUNT(*) FROM axiom_release_train_steps st WHERE st.release_train_id = rt.id "
            . "AND st.organisation_id = rt.organisation_id AND st.archived_at IS NULL "
            . "AND st.status = 'blocked') AS blocked_steps, "
            . '(SELECT COUNT(*) FROM axiom_release_milestones m '
            . 'WHERE m.release_train_id = rt.id AND m.organisation_id = rt.organisation_id '
            . 'AND m.archived_at IS NULL) AS milestone_count, '
            . "(SELECT COUNT(*) FROM axiom_release_milestones m WHERE m.release_train_id = rt.id "
            . "AND m.organisation_id = rt.organisation_id AND m.archived_at IS NULL "
            . "AND m.status = 'completed') AS completed_milestones, "
            . "(SELECT COUNT(*) FROM axiom_release_milestones m WHERE m.release_train_id = rt.id "
            . "AND m.organisation_id = rt.organisation_id AND m.archived_at IS NULL "
            . "AND m.status NOT IN ('completed', 'cancelled') AND m.planned_end_at IS NOT NULL "
            . 'AND m.planned_end_at < NOW()) AS overdue_milestones, '
            . '(SELECT MIN(COALESCE(m.planned_start_at, m.planned_end_at)) FROM axiom_release_milestones m '
            . 'WHERE m.release_train_id = rt.id AND m.organisation_id = rt.organisation_id '
            . 'AND m.archived_at IS NULL) AS roadmap_start_at, '
            . '(SELECT MAX(COALESCE(m.planned_end_at, m.planned_start_at)) FROM axiom_release_milestones m '
            . 'WHERE m.release_train_id = rt.id AND m.organisation_id = rt.organisation_id '
            . 'AND m.archived_at IS NULL) AS roadmap_end_at '
            . 'FROM axiom_release_trains rt '
            . 'WHERE rt.organisation_id = :organisation_id AND rt.archived_at IS NULL '
            . 'ORDER BY COALESCE(rt.planned_at, roadmap_start_at) ASC, rt.id ASC'
        );
        $statement->execute(['organisation_id' => $organisationId]);
        $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
        foreach ($rows as &$row) {
            $stepCount = (int) $row['step_count'];
            $milestoneCount = (int) $row['milestone_count'];
            $stepProgress = $stepCount > 0
                ? ((int) $row['completed_steps'] / $stepCount) * 100
                : 0;
            $milestoneProgress = $milestoneCount > 0
                ? ((int) $row['completed_milestones'] / $milestoneCount) * 100
                : 0;
            if ($stepCount > 0 && $milestoneCount > 0) {
                $row['roadmap_progress'] = (int) round(($stepProgress + $milestoneProgress) / 2);
            } elseif ($stepCount > 0) {
                $row['roadmap_progress'] = (int) round($stepProgress);
            } elseif ($milestoneCount > 0) {
                $row['roadmap_progress'] = (int) round($milestoneProgress);
            } else {
                $row['roadmap_progress'] = 0;
            }
            $row['is_late'] = (int) $row['overdue_milestones'] > 0;
        }
        unset($row);
        return $rows;
    }

    /** @return array<string,mixed> */
    public function executiveDashboard(int $organisationId): array
    {
        $trains = $this->roadmap($organisationId);
        $statusCounts = [
            'draft' => 0,
            'planned' => 0,
            'active' => 0,
            'completed' => 0,
            'cancelled' => 0,
        ];
        $totalProgress = 0;
        $totalSteps = 0;
        $completedSteps = 0;
        $blockedSteps = 0;
        $totalMilestones = 0;
        $completedMilestones = 0;
        $overdueMilestones = 0;
        $highRisk = 0;
        $mediumRisk = 0;
        $alerts = [];
        $deadlines = [];

        foreach ($trains as &$train) {
            $status = (string) ($train['status'] ?? 'draft');
            if (array_key_exists($status, $statusCounts)) {
                $statusCounts[$status]++;
            }
            $progress = (int) ($train['roadmap_progress'] ?? 0);
            $totalProgress += $progress;
            $totalSteps += (int) ($train['step_count'] ?? 0);
            $completedSteps += (int) ($train['completed_steps'] ?? 0);
            $blocked = (int) ($train['blocked_steps'] ?? 0);
            $blockedSteps += $blocked;
            $totalMilestones += (int) ($train['milestone_count'] ?? 0);
            $completedMilestones += (int) ($train['completed_milestones'] ?? 0);
            $overdue = (int) ($train['overdue_milestones'] ?? 0);
            $overdueMilestones += $overdue;

            $riskScore = ($blocked * 2) + ($overdue * 2);
            if ($status === 'active' && $progress < 25) {
                $riskScore++;
            }
            if ($riskScore >= 5) {
                $train['executive_risk'] = 'high';
                $train['executive_risk_label'] = 'Élevé';
                $highRisk++;
            } elseif ($riskScore > 0) {
                $train['executive_risk'] = 'medium';
                $train['executive_risk_label'] = 'Moyen';
                $mediumRisk++;
            } else {
                $train['executive_risk'] = 'low';
                $train['executive_risk_label'] = 'Faible';
            }

            if ($blocked > 0) {
                $alerts[] = [
                    'level' => 'high',
                    'train_id' => (int) $train['id'],
                    'title' => (string) $train['code'],
                    'message' => $blocked . ' étape(s) bloquée(s).',
                ];
            }
            if ($overdue > 0) {
                $alerts[] = [
                    'level' => 'high',
                    'train_id' => (int) $train['id'],
                    'title' => (string) $train['code'],
                    'message' => $overdue . ' jalon(s) en retard.',
                ];
            }

            $deadline = $train['roadmap_end_at'] ?: $train['planned_at'];
            if (!empty($deadline) && !in_array($status, ['completed', 'cancelled'], true)) {
                $deadlines[] = [
                    'train_id' => (int) $train['id'],
                    'code' => (string) $train['code'],
                    'name' => (string) $train['name'],
                    'date' => (string) $deadline,
                    'progress' => $progress,
                ];
            }
        }
        unset($train);

        usort($deadlines, static fn(array $left, array $right): int => strcmp($left['date'], $right['date']));
        usort($alerts, static fn(array $left, array $right): int => strcmp($left['level'], $right['level']));

        $count = count($trains);
        return [
            'trains' => $trains,
            'train_count' => $count,
            'status_counts' => $statusCounts,
            'global_progress' => $count > 0 ? (int) round($totalProgress / $count) : 0,
            'steps' => ['total' => $totalSteps, 'completed' => $completedSteps, 'blocked' => $blockedSteps],
            'milestones' => ['total' => $totalMilestones, 'completed' => $completedMilestones, 'overdue' => $overdueMilestones],
            'risk' => ['high' => $highRisk, 'medium' => $mediumRisk, 'low' => max(0, $count - $highRisk - $mediumRisk)],
            'alerts' => array_slice($alerts, 0, 8),
            'deadlines' => array_slice($deadlines, 0, 6),
        ];
    }

    public function find(int $id, int $organisationId): ?array
    {
        $statement = Database::connection()->prepare(
            'SELECT * FROM axiom_release_trains '
            . 'WHERE id = :id AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute(['id' => $id, 'organisation_id' => $organisationId]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    public function steps(int $trainId, int $organisationId): array
    {
        $this->requireTrain($trainId, $organisationId);
        $statement = Database::connection()->prepare(
            'SELECT * FROM axiom_release_train_steps '
            . 'WHERE release_train_id = :train_id '
            . 'AND organisation_id = :organisation_id '
            . 'AND archived_at IS NULL '
            . 'ORDER BY position ASC, id ASC'
        );
        $statement->execute(['train_id' => $trainId, 'organisation_id' => $organisationId]);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    public function stepsWithDependencies(int $trainId, int $organisationId): array
    {
        $steps = $this->steps($trainId, $organisationId);
        $dependencies = $this->dependencies($trainId, $organisationId);
        $byStep = [];
        foreach ($dependencies as $dependency) {
            $byStep[(int) $dependency['step_id']][] = $dependency;
        }
        foreach ($steps as &$step) {
            $stepDependencies = $byStep[(int) $step['id']] ?? [];
            $step['dependencies'] = $stepDependencies;
            $step['unmet_dependencies'] = array_values(array_filter(
                $stepDependencies,
                static fn(array $dependency): bool => $dependency['depends_on_status'] !== 'completed'
            ));
        }
        unset($step);
        return $steps;
    }

    public function dependencies(int $trainId, int $organisationId): array
    {
        $this->requireTrain($trainId, $organisationId);
        $statement = Database::connection()->prepare(
            'SELECT d.id, d.step_id, d.depends_on_step_id, prerequisite.title AS depends_on_title, '
            . 'prerequisite.status AS depends_on_status '
            . 'FROM axiom_release_train_step_dependencies d '
            . 'INNER JOIN axiom_release_train_steps prerequisite '
            . 'ON prerequisite.id = d.depends_on_step_id '
            . 'AND prerequisite.organisation_id = d.organisation_id '
            . 'AND prerequisite.release_train_id = d.release_train_id '
            . 'AND prerequisite.archived_at IS NULL '
            . 'WHERE d.release_train_id = :train_id '
            . 'AND d.organisation_id = :organisation_id '
            . 'AND d.archived_at IS NULL '
            . 'ORDER BY d.id ASC'
        );
        $statement->execute(['train_id' => $trainId, 'organisation_id' => $organisationId]);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    public function findStep(int $id, int $trainId, int $organisationId): ?array
    {
        $statement = Database::connection()->prepare(
            'SELECT * FROM axiom_release_train_steps '
            . 'WHERE id = :id AND release_train_id = :train_id '
            . 'AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute([
            'id' => $id,
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
        ]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    public function progress(int $trainId, int $organisationId): array
    {
        $steps = $this->steps($trainId, $organisationId);
        $total = count($steps);
        $completed = count(array_filter(
            $steps,
            static fn(array $step): bool => $step['status'] === 'completed'
        ));
        return [
            'total' => $total,
            'completed' => $completed,
            'percent' => $total > 0 ? (int) round($completed / $total * 100) : 0,
        ];
    }

    public function risk(int $trainId, int $organisationId): array
    {
        $steps = $this->stepsWithDependencies($trainId, $organisationId);
        $blocked = 0;
        $overdue = 0;
        $now = time();
        foreach ($steps as $step) {
            if ($step['status'] === 'blocked' || count($step['unmet_dependencies']) > 0) {
                $blocked++;
            }
            if ($step['status'] !== 'completed' && !empty($step['planned_end_at'])) {
                $plannedEnd = strtotime((string) $step['planned_end_at']);
                if ($plannedEnd !== false && $plannedEnd < $now) {
                    $overdue++;
                }
            }
        }
        $score = ($blocked * 2) + $overdue;
        if ($score >= 5) {
            return ['level' => 'high', 'label' => 'Risque élevé', 'blocked' => $blocked, 'overdue' => $overdue];
        }
        if ($score > 0) {
            return ['level' => 'medium', 'label' => 'À surveiller', 'blocked' => $blocked, 'overdue' => $overdue];
        }
        return ['level' => 'low', 'label' => 'Maîtrisé', 'blocked' => 0, 'overdue' => 0];
    }

    public function save(array $input, ?int $id, int $actorId, int $organisationId): int
    {
        $code = strtoupper(trim((string) ($input['code'] ?? '')));
        $name = trim((string) ($input['name'] ?? ''));
        $version = trim((string) ($input['target_version'] ?? ''));
        $status = (string) ($input['status'] ?? 'draft');
        if (!preg_match('/^[A-Z0-9][A-Z0-9._-]{2,79}$/', $code)) {
            throw new RuntimeException('Code invalide.');
        }
        if ($name === '' || mb_strlen($name) > 160) {
            throw new RuntimeException('Nom obligatoire (160 caractères maximum).');
        }
        if ($version === '' || mb_strlen($version) > 50) {
            throw new RuntimeException('Version cible obligatoire.');
        }
        if (!in_array($status, self::TRAIN_STATUSES, true)) {
            throw new RuntimeException('Statut invalide.');
        }
        $plannedAt = $this->dateOrNull($input['planned_at'] ?? null);
        $pdo = Database::connection();
        if ($id !== null) {
            $statement = $pdo->prepare(
                'UPDATE axiom_release_trains SET code = :code, name = :name, '
                . 'description = :description, target_version = :target_version, status = :status, '
                . 'planned_at = :planned_at, updated_by = :actor_id, updated_at = NOW() '
                . 'WHERE id = :id AND organisation_id = :organisation_id AND archived_at IS NULL'
            );
            $statement->execute([
                'code' => $code,
                'name' => $name,
                'description' => trim((string) ($input['description'] ?? '')),
                'target_version' => $version,
                'status' => $status,
                'planned_at' => $plannedAt,
                'actor_id' => $actorId ?: null,
                'id' => $id,
                'organisation_id' => $organisationId,
            ]);
            if (!$statement->rowCount() && !$this->find($id, $organisationId)) {
                throw new RuntimeException('Release train introuvable.');
            }
            $this->audit('release_train.updated', $id, $actorId, $code);
            return $id;
        }
        $statement = $pdo->prepare(
            'INSERT INTO axiom_release_trains '
            . '(organisation_id, code, name, description, target_version, status, planned_at, '
            . 'created_by, updated_by, created_at, updated_at) '
            . 'VALUES (:organisation_id, :code, :name, :description, :target_version, :status, '
            . ':planned_at, :actor_id, :actor_id, NOW(), NOW())'
        );
        $statement->execute([
            'organisation_id' => $organisationId,
            'code' => $code,
            'name' => $name,
            'description' => trim((string) ($input['description'] ?? '')),
            'target_version' => $version,
            'status' => $status,
            'planned_at' => $plannedAt,
            'actor_id' => $actorId ?: null,
        ]);
        $newId = (int) $pdo->lastInsertId();
        $this->audit('release_train.created', $newId, $actorId, $code);
        return $newId;
    }

    public function saveStep(
        array $input,
        ?int $id,
        int $trainId,
        int $actorId,
        int $organisationId
    ): int {
        $this->requireTrain($trainId, $organisationId);
        $title = trim((string) ($input['title'] ?? ''));
        $status = (string) ($input['step_status'] ?? 'todo');
        $position = max(1, min(9999, (int) ($input['position'] ?? 10)));
        $blockedReason = trim((string) ($input['blocked_reason'] ?? ''));
        if ($title === '' || mb_strlen($title) > 180) {
            throw new RuntimeException('Titre de l’étape obligatoire (180 caractères maximum).');
        }
        if (!in_array($status, self::STEP_STATUSES, true)) {
            throw new RuntimeException('Statut d’étape invalide.');
        }
        if ($status === 'blocked' && $blockedReason === '') {
            throw new RuntimeException('Le motif de blocage est obligatoire pour une étape bloquée.');
        }
        if (mb_strlen($blockedReason) > 500) {
            throw new RuntimeException('Motif de blocage trop long (500 caractères maximum).');
        }
        if ($id !== null && $status === 'completed') {
            $unmet = $this->unmetDependencies($id, $trainId, $organisationId);
            if ($unmet !== []) {
                throw new RuntimeException('Impossible de terminer cette étape : des prérequis ne sont pas terminés.');
            }
        }

        $data = [
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
            'title' => $title,
            'description' => trim((string) ($input['step_description'] ?? '')),
            'status' => $status,
            'blocked_reason' => $status === 'blocked' ? $blockedReason : null,
            'position' => $position,
            'responsible_user_id' => $this->positiveIntOrNull($input['responsible_user_id'] ?? null),
            'planned_start_at' => $this->dateOrNull($input['planned_start_at'] ?? null),
            'planned_end_at' => $this->dateOrNull($input['planned_end_at'] ?? null),
            'actual_start_at' => $this->dateOrNull($input['actual_start_at'] ?? null),
            'actual_end_at' => $this->dateOrNull($input['actual_end_at'] ?? null),
            'actor_id' => $actorId ?: null,
        ];
        if ($data['planned_start_at'] && $data['planned_end_at'] && $data['planned_end_at'] < $data['planned_start_at']) {
            throw new RuntimeException('La fin prévisionnelle doit être postérieure au début.');
        }
        if ($data['actual_start_at'] && $data['actual_end_at'] && $data['actual_end_at'] < $data['actual_start_at']) {
            throw new RuntimeException('La fin réelle doit être postérieure au début.');
        }

        $pdo = Database::connection();
        $pdo->beginTransaction();
        try {
            if ($id !== null) {
                $data['id'] = $id;
                $statement = $pdo->prepare(
                    'UPDATE axiom_release_train_steps SET title = :title, description = :description, '
                    . 'status = :status, blocked_reason = :blocked_reason, position = :position, '
                    . 'responsible_user_id = :responsible_user_id, planned_start_at = :planned_start_at, '
                    . 'planned_end_at = :planned_end_at, actual_start_at = :actual_start_at, '
                    . 'actual_end_at = :actual_end_at, updated_by = :actor_id, updated_at = NOW() '
                    . 'WHERE id = :id AND release_train_id = :train_id '
                    . 'AND organisation_id = :organisation_id AND archived_at IS NULL'
                );
                $statement->execute($data);
                if (!$statement->rowCount() && !$this->findStep($id, $trainId, $organisationId)) {
                    throw new RuntimeException('Étape introuvable.');
                }
                $stepId = $id;
                $event = 'release_train.step_updated';
            } else {
                $statement = $pdo->prepare(
                    'INSERT INTO axiom_release_train_steps '
                    . '(organisation_id, release_train_id, title, description, status, blocked_reason, '
                    . 'position, responsible_user_id, planned_start_at, planned_end_at, actual_start_at, '
                    . 'actual_end_at, created_by, updated_by, created_at, updated_at) '
                    . 'VALUES (:organisation_id, :train_id, :title, :description, :status, :blocked_reason, '
                    . ':position, :responsible_user_id, :planned_start_at, :planned_end_at, :actual_start_at, '
                    . ':actual_end_at, :actor_id, :actor_id, NOW(), NOW())'
                );
                $statement->execute($data);
                $stepId = (int) $pdo->lastInsertId();
                $event = 'release_train.step_created';
            }
            $pdo->commit();
            $this->audit($event, $stepId, $actorId, $title);
            return $stepId;
        } catch (Throwable $exception) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
            throw $exception;
        }
    }

    public function addDependency(
        int $stepId,
        int $dependsOnStepId,
        int $trainId,
        int $actorId,
        int $organisationId
    ): void {
        if ($stepId === $dependsOnStepId) {
            throw new RuntimeException('Une étape ne peut pas dépendre d’elle-même.');
        }
        $this->requireStep($stepId, $trainId, $organisationId);
        $this->requireStep($dependsOnStepId, $trainId, $organisationId);
        if ($this->wouldCreateCycle($stepId, $dependsOnStepId, $trainId, $organisationId)) {
            throw new RuntimeException('Dépendance refusée : un cycle serait créé.');
        }
        $pdo = Database::connection();
        $existing = $pdo->prepare(
            'SELECT id FROM axiom_release_train_step_dependencies '
            . 'WHERE organisation_id = :organisation_id AND release_train_id = :train_id '
            . 'AND step_id = :step_id AND depends_on_step_id = :depends_on_step_id '
            . 'AND archived_at IS NULL'
        );
        $existing->execute([
            'organisation_id' => $organisationId,
            'train_id' => $trainId,
            'step_id' => $stepId,
            'depends_on_step_id' => $dependsOnStepId,
        ]);
        if ($existing->fetchColumn()) {
            throw new RuntimeException('Cette dépendance existe déjà.');
        }
        $statement = $pdo->prepare(
            'INSERT INTO axiom_release_train_step_dependencies '
            . '(organisation_id, release_train_id, step_id, depends_on_step_id, created_by, created_at) '
            . 'VALUES (:organisation_id, :train_id, :step_id, :depends_on_step_id, :actor_id, NOW())'
        );
        $statement->execute([
            'organisation_id' => $organisationId,
            'train_id' => $trainId,
            'step_id' => $stepId,
            'depends_on_step_id' => $dependsOnStepId,
            'actor_id' => $actorId ?: null,
        ]);
        $this->audit('release_train.dependency_created', (int) $pdo->lastInsertId(), $actorId, $stepId . '>' . $dependsOnStepId);
    }


    public function moveStep(
        int $stepId,
        int $trainId,
        string $status,
        int $actorId,
        int $organisationId
    ): array {
        if (!in_array($status, self::STEP_STATUSES, true)) {
            throw new RuntimeException('Statut de destination invalide.');
        }

        $step = $this->requireStep($stepId, $trainId, $organisationId);
        if ($status === 'completed') {
            $unmet = $this->unmetDependencies($stepId, $trainId, $organisationId);
            if ($unmet !== []) {
                $titles = array_map(
                    static fn(array $dependency): string => (string) $dependency['title'],
                    $unmet
                );
                throw new RuntimeException(
                    'Déplacement refusé : prérequis non terminés — ' . implode(', ', $titles) . '.'
                );
            }
        }

        $blockedReason = $status === 'blocked'
            ? trim((string) ($step['blocked_reason'] ?? ''))
            : null;
        if ($status === 'blocked' && $blockedReason === '') {
            $blockedReason = 'Blocage déclaré depuis le pipeline visuel.';
        }

        $actualStartAt = $step['actual_start_at'] ?? null;
        $actualEndAt = $step['actual_end_at'] ?? null;
        if ($status === 'in_progress' && empty($actualStartAt)) {
            $actualStartAt = date('Y-m-d H:i:s');
        }
        if ($status === 'completed') {
            $actualStartAt = $actualStartAt ?: date('Y-m-d H:i:s');
            $actualEndAt = $actualEndAt ?: date('Y-m-d H:i:s');
        } elseif ((string) $step['status'] === 'completed') {
            $actualEndAt = null;
        }

        $statement = Database::connection()->prepare(
            'UPDATE axiom_release_train_steps '
            . 'SET status = :status, blocked_reason = :blocked_reason, '
            . 'actual_start_at = :actual_start_at, actual_end_at = :actual_end_at, '
            . 'updated_by = :actor_id, updated_at = NOW() '
            . 'WHERE id = :id AND release_train_id = :train_id '
            . 'AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute([
            'status' => $status,
            'blocked_reason' => $blockedReason,
            'actual_start_at' => $actualStartAt,
            'actual_end_at' => $actualEndAt,
            'actor_id' => $actorId ?: null,
            'id' => $stepId,
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
        ]);

        if (!$statement->rowCount() && (string) $step['status'] !== $status) {
            throw new RuntimeException('Le déplacement de l’étape a échoué.');
        }

        $this->audit(
            'release_train.step_moved',
            $stepId,
            $actorId,
            (string) $step['status'] . '>' . $status
        );

        return [
            'step' => $this->requireStep($stepId, $trainId, $organisationId),
            'progress' => $this->progress($trainId, $organisationId),
            'risk' => $this->risk($trainId, $organisationId),
        ];
    }

    public function archiveDependency(
        int $dependencyId,
        int $trainId,
        int $actorId,
        int $organisationId
    ): void {
        $this->requireTrain($trainId, $organisationId);
        $statement = Database::connection()->prepare(
            'UPDATE axiom_release_train_step_dependencies SET archived_at = NOW() '
            . 'WHERE id = :id AND release_train_id = :train_id '
            . 'AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute([
            'id' => $dependencyId,
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
        ]);
        if (!$statement->rowCount()) {
            throw new RuntimeException('Dépendance introuvable ou déjà retirée.');
        }
        $this->audit('release_train.dependency_archived', $dependencyId, $actorId, (string) $dependencyId);
    }

    public function archive(int $id, int $actorId, int $organisationId): void
    {
        $statement = Database::connection()->prepare(
            'UPDATE axiom_release_trains SET archived_at = NOW(), updated_by = :actor_id, updated_at = NOW() '
            . 'WHERE id = :id AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute(['actor_id' => $actorId ?: null, 'id' => $id, 'organisation_id' => $organisationId]);
        if (!$statement->rowCount()) {
            throw new RuntimeException('Release train introuvable ou déjà archivé.');
        }
        $this->audit('release_train.archived', $id, $actorId, (string) $id);
    }

    public function archiveStep(int $id, int $trainId, int $actorId, int $organisationId): void
    {
        $this->requireTrain($trainId, $organisationId);
        $statement = Database::connection()->prepare(
            'UPDATE axiom_release_train_steps SET archived_at = NOW(), updated_by = :actor_id, updated_at = NOW() '
            . 'WHERE id = :id AND release_train_id = :train_id '
            . 'AND organisation_id = :organisation_id AND archived_at IS NULL'
        );
        $statement->execute([
            'actor_id' => $actorId ?: null,
            'id' => $id,
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
        ]);
        if (!$statement->rowCount()) {
            throw new RuntimeException('Étape introuvable ou déjà archivée.');
        }
        $this->audit('release_train.step_archived', $id, $actorId, (string) $id);
    }

    private function unmetDependencies(int $stepId, int $trainId, int $organisationId): array
    {
        $statement = Database::connection()->prepare(
            'SELECT prerequisite.id, prerequisite.title '
            . 'FROM axiom_release_train_step_dependencies d '
            . 'INNER JOIN axiom_release_train_steps prerequisite '
            . 'ON prerequisite.id = d.depends_on_step_id '
            . 'AND prerequisite.organisation_id = d.organisation_id '
            . 'AND prerequisite.release_train_id = d.release_train_id '
            . 'AND prerequisite.archived_at IS NULL '
            . 'WHERE d.step_id = :step_id AND d.release_train_id = :train_id '
            . 'AND d.organisation_id = :organisation_id AND d.archived_at IS NULL '
            . "AND prerequisite.status <> 'completed'"
        );
        $statement->execute([
            'step_id' => $stepId,
            'train_id' => $trainId,
            'organisation_id' => $organisationId,
        ]);
        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    private function wouldCreateCycle(
        int $stepId,
        int $dependsOnStepId,
        int $trainId,
        int $organisationId
    ): bool {
        $statement = Database::connection()->prepare(
            'SELECT step_id, depends_on_step_id FROM axiom_release_train_step_dependencies '
            . 'WHERE release_train_id = :train_id AND organisation_id = :organisation_id '
            . 'AND archived_at IS NULL'
        );
        $statement->execute(['train_id' => $trainId, 'organisation_id' => $organisationId]);
        $graph = [];
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $graph[(int) $row['step_id']][] = (int) $row['depends_on_step_id'];
        }
        $graph[$stepId][] = $dependsOnStepId;
        $stack = [$dependsOnStepId];
        $visited = [];
        while ($stack !== []) {
            $current = array_pop($stack);
            if ($current === $stepId) {
                return true;
            }
            if (isset($visited[$current])) {
                continue;
            }
            $visited[$current] = true;
            foreach ($graph[$current] ?? [] as $next) {
                $stack[] = $next;
            }
        }
        return false;
    }

    private function requireTrain(int $id, int $organisationId): array
    {
        $train = $this->find($id, $organisationId);
        if (!$train) {
            throw new RuntimeException('Release train introuvable.');
        }
        return $train;
    }

    private function requireStep(int $id, int $trainId, int $organisationId): array
    {
        $step = $this->findStep($id, $trainId, $organisationId);
        if (!$step) {
            throw new RuntimeException('Étape introuvable.');
        }
        return $step;
    }

    private function dateOrNull(mixed $value): ?string
    {
        $date = trim((string) $value);
        if ($date === '') {
            return null;
        }
        $timestamp = strtotime($date);
        if ($timestamp === false) {
            throw new RuntimeException('Date invalide.');
        }
        return date('Y-m-d H:i:s', $timestamp);
    }

    private function positiveIntOrNull(mixed $value): ?int
    {
        $integer = (int) $value;
        return $integer > 0 ? $integer : null;
    }

    private function audit(string $event, int $id, int $actorId, string $details): void
    {
        try {
            (new AuditLogger())->record(
                $event,
                'release_train_manager',
                'release_train',
                $id,
                null,
                ['actor_id' => $actorId ?: null, 'details' => $details]
            );
        } catch (Throwable) {
        }
    }
}
