<?php

namespace modules\selfserve\classes;

require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/objects/departments_o.php';
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
require_once WD . '/objects/department_selfserve_conditions_o.php';
require_once WD . '/objects/department_selfserve_questions_o.php';
require_once WD . '/objects/department_selfserve_tasks_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';

use classes\selfserve_schema_bootstrap;
use modules\selfserve\helpers\selfserve_task_gate_type;
use modules\selfserve\classes\selfserve_studio_actions;
use objects\departments_o;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
use objects\selfserve_config_versions_o;

class selfserve_config_versioning
{
    public const STATUS_DRAFT = 'DRAFT';
    public const STATUS_PUBLISHED = 'PUBLISHED';
    public const STATUS_ARCHIVED = 'ARCHIVED';
    public const SCHEMA_VERSION_V2 = 2;

    private const V2_PREDICATE_OPERATORS = [
        'IS_TRUE',
        'IS_FALSE',
        'IS_SET',
        'IS_TRUE_OR_NOT_SET',
        'IS_FALSE_OR_NOT_SET',
    ];

    public function __construct()
    {
        selfserve_schema_bootstrap::ensureTables();
    }

    /**
     * @return array{version_id:int,config:array<string,mixed>}|null
     */
    public function getPublishedConfig(int $departmentId): ?array
    {
        $version = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_PUBLISHED);
        if (!$version->exists()) {
            return null;
        }

        return [
            'version_id' => (int)$version->id,
            'config' => (array)($version->config_json->value() ?? []),
        ];
    }

    /**
     * @return array{version_id:int,config:array<string,mixed>}|null
     */
    public function getPublishedV2Config(int $departmentId): ?array
    {
        $published = $this->getPublishedConfig($departmentId);
        if (!is_array($published) || !$this->isV2Config((array)($published['config'] ?? []))) {
            return null;
        }

        $published['config'] = $this->normalizeV2Config((array)$published['config']);
        return $published;
    }

    public function isV2Config(array $config): bool
    {
        return (int)($config['schema_version'] ?? 0) === self::SCHEMA_VERSION_V2;
    }

    /**
     * @return array<string,mixed>
     */
    public function ensureDraftFromLegacy(int $departmentId, ?int $createdBy = null, bool $forceRefresh = false): array
    {
        $versionObject = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
        $config = $this->snapshotLegacyConfig($departmentId);
        $validation = $this->validateConfig($config);

        if ($versionObject->exists()) {
            $existingConfig = (array)($versionObject->config_json->value() ?? []);
            if ($forceRefresh || !$this->isV2Config($existingConfig)) {
                $nextConfig = $forceRefresh ? $config : $this->migrateLegacyConfigToV2($existingConfig + ['department_id' => $departmentId]);
                $versionObject->config_json->set($nextConfig);
                $versionObject->validation_result_json->set($this->validateConfig($nextConfig));
            } else {
                $normalizedConfig = $this->normalizeV2Config($existingConfig + ['department_id' => $departmentId]);
                if ($normalizedConfig !== $existingConfig) {
                    $versionObject->config_json->set($normalizedConfig);
                    $versionObject->validation_result_json->set($this->validateConfig($normalizedConfig));
                }
            }
            return $versionObject->asArray();
        }

        $latestVersionNumber = $this->getLatestVersionNumber($departmentId);
        $newVersion = (new selfserve_config_versions_o())->add(
            $departmentId,
            self::STATUS_DRAFT,
            $latestVersionNumber + 1,
            $config,
            $validation,
            null,
            $createdBy,
            null,
        );
        return $newVersion->asArray();
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function listVersions(int $departmentId): array
    {
        return (new selfserve_config_versions_o())->listByDepartment($departmentId);
    }

    /**
     * @return array<string,mixed>
     */
    public function validateDraft(int $departmentId): array
    {
        $draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
        if (!$draft->exists()) {
            $created = $this->ensureDraftFromLegacy($departmentId);
            $draft = (new selfserve_config_versions_o())->select((int)$created['id']);
        }

        $validation = $this->validateConfig((array)($draft->config_json->value() ?? []));
        $draft->validation_result_json->set($validation);

        return [
            'version' => $draft->asArray(),
            'validation' => $validation,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public function publishDraft(int $departmentId, ?int $publishedBy = null): array
    {
        $draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus($departmentId, self::STATUS_DRAFT);
        if (!$draft->exists()) {
            $created = $this->ensureDraftFromLegacy($departmentId, $publishedBy);
            $draft = (new selfserve_config_versions_o())->select((int)$created['id']);
        }

        $config = (array)($draft->config_json->value() ?? []);
        if (!$this->isV2Config($config)) {
            $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
            $draft->config_json->set($config);
        }

        $validation = $this->validateConfig($config);
        $draft->validation_result_json->set($validation);
        if (($validation['valid'] ?? false) !== true) {
            throw new \RuntimeException('Draft validation failed. Resolve errors before publishing.');
        }

        $this->archivePublishedVersions($departmentId);
        $draft->status->set(self::STATUS_PUBLISHED);
        $draft->published_at->set(date('Y-m-d H:i:s'));
        if ($publishedBy !== null) {
            $draft->created_by->set($publishedBy);
        }

        // Keep editing path open by creating a new draft cloned from newly published version.
        $publishedArray = $draft->asArray();
        $this->createDraftFromConfig($departmentId, $config, (int)$draft->id, $publishedBy);

        return $publishedArray;
    }

    /**
     * @return array<string,mixed>
     */
    public function rollbackToVersion(int $departmentId, int $targetVersionId, ?int $createdBy = null): array
    {
        $target = (new selfserve_config_versions_o())->select($targetVersionId);
        if (!$target->exists() || (int)$target->department_id->value() !== $departmentId) {
            throw new \RuntimeException('Target version not found for department.');
        }

        $config = (array)($target->config_json->value() ?? []);
        if (!$this->isV2Config($config)) {
            $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
        }
        $validation = $this->validateConfig($config);
        if (($validation['valid'] ?? false) !== true) {
            throw new \RuntimeException('Target version cannot be rolled back because validation fails.');
        }

        $this->archivePublishedVersions($departmentId);
        $latestVersionNumber = $this->getLatestVersionNumber($departmentId);
        $rollbackVersion = (new selfserve_config_versions_o())->add(
            $departmentId,
            self::STATUS_PUBLISHED,
            $latestVersionNumber + 1,
            $config,
            $validation,
            $targetVersionId,
            $createdBy,
            date('Y-m-d H:i:s'),
        );

        $this->createDraftFromConfig($departmentId, $config, (int)$rollbackVersion->id, $createdBy);

        return $rollbackVersion->asArray();
    }

    public function syncDraftFromLegacyForDepartment(int $departmentId): void
    {
        if ($departmentId > 0) {
            $this->ensureDraftFromLegacy($departmentId, null, true);
            return;
        }

        foreach ($this->getKnownDepartmentIdsForSync() as $id) {
            $this->ensureDraftFromLegacy($id, null, true);
        }
    }

    /**
     * @return array<string,mixed>
     */
    public function snapshotLegacyConfig(int $departmentId): array
    {
        return $this->migrateLegacyConfigToV2($this->snapshotLegacyTableConfig($departmentId));
    }

    /**
     * @return array<string,mixed>
     */
    protected function snapshotLegacyTableConfig(int $departmentId): array
    {
        $questionsObject = new department_selfserve_questions_o();
        $conditionsObject = new department_selfserve_conditions_o();
        $tasksObject = new department_selfserve_tasks_o();
        $rulesObject = new department_selfserve_condition_rules_o();

        $departmentFilter = [0, $departmentId];

        $questions = $questionsObject->getFieldsWhereIn([
            'department' => $departmentFilter,
            'deleted_at' => null,
        ], ['id', 'department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority', 'created_at', 'updated_at']);

        $conditions = $conditionsObject->getFieldsWhereIn([
            'department' => $departmentFilter,
            'deleted_at' => null,
        ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'created_at', 'updated_at']);

        $tasks = $tasksObject->getFieldsWhereIn([
            'department' => $departmentFilter,
            'deleted_at' => null,
        ], ['id', 'department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type', 'created_at', 'updated_at']);

        $conditionIds = array_map(static fn(array $condition): int => (int)$condition['id'], $conditions);
        $rules = $conditionIds === []
            ? []
            : $rulesObject->getFieldsWhereIn([
                'condition_id' => $conditionIds,
                'deleted_at' => null,
            ], ['id', 'condition_id', 'type', 'object_type', 'object_id', 'name', 'description']);

        $conditionIdMap = array_fill_keys($conditionIds, true);
        $tasks = array_map(fn(array $task): array => $this->normalizeTaskGate($task, $conditionIdMap), $tasks);

        usort($questions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
        usort($conditions, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
        usort($rules, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);
        usort($tasks, static fn(array $a, array $b): int => (int)$a['id'] <=> (int)$b['id']);

        return [
            'department_id' => $departmentId,
            'questions' => $questions,
            'conditions' => $conditions,
            'rules' => $rules,
            'tasks' => $tasks,
            'actions' => [],
            'snapshot_meta' => [
                'captured_at' => date('c'),
                'source' => 'legacy_tables',
            ],
        ];
    }

    /**
     * @param array<string,mixed> $legacyConfig
     * @return array<string,mixed>
     */
    public function migrateLegacyConfigToV2(array $legacyConfig): array
    {
        if ($this->isV2Config($legacyConfig)) {
            return $this->normalizeV2Config($legacyConfig);
        }

        $rulesByCondition = [];
        foreach ((array)($legacyConfig['rules'] ?? []) as $rule) {
            if (!is_array($rule)) {
                continue;
            }
            $rulesByCondition[(int)($rule['condition_id'] ?? 0)][] = $rule;
        }

        $migrationIssues = [];
        $conditions = [];
        foreach ((array)($legacyConfig['conditions'] ?? []) as $condition) {
            if (!is_array($condition)) {
                continue;
            }
            $conditionId = (int)($condition['id'] ?? 0);
            $conditionRules = array_values((array)($rulesByCondition[$conditionId] ?? []));
            $condition['expression'] = $this->migrateLegacyRulesToExpression($conditionId, $conditionRules, $migrationIssues);
            $conditions[] = $condition;
        }

        $config = [
            'schema_version' => self::SCHEMA_VERSION_V2,
            'department_id' => (int)($legacyConfig['department_id'] ?? 0),
            'questions' => array_values((array)($legacyConfig['questions'] ?? [])),
            'conditions' => array_values($conditions),
            'rules' => [],
            'tasks' => array_values((array)($legacyConfig['tasks'] ?? [])),
            'actions' => array_values((array)($legacyConfig['actions'] ?? [])),
            'v2_meta' => [
                'migrated_from' => (int)($legacyConfig['schema_version'] ?? 1),
                'migrated_at' => date('c'),
                'source' => (string)($legacyConfig['snapshot_meta']['source'] ?? 'legacy_config'),
                'next_ids' => $this->nextIdsForConfig($legacyConfig),
            ],
        ];

        if ($migrationIssues !== []) {
            $config['migration_issues'] = $migrationIssues;
        }

        return $this->normalizeV2Config($config);
    }

    /**
     * @param array<string,mixed> $config
     * @return array<string,mixed>
     */
    public function validateConfig(array $config): array
    {
        if ($this->isV2Config($config)) {
            return $this->validateV2Config($this->normalizeV2Config($config));
        }

        $errors = [];
        $warnings = [];

        $questions = is_array($config['questions'] ?? null) ? $config['questions'] : [];
        $conditions = is_array($config['conditions'] ?? null) ? $config['conditions'] : [];
        $rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
        $tasks = is_array($config['tasks'] ?? null) ? $config['tasks'] : [];
        $actions = is_array($config['actions'] ?? null) ? $config['actions'] : [];

        $questionIds = [];
        foreach ($questions as $question) {
            $id = (int)($question['id'] ?? 0);
            if ($id <= 0) {
                $errors[] = 'Question without valid id.';
                continue;
            }
            $questionIds[$id] = true;
        }

        $conditionIds = [];
        $conditionParents = [];
        foreach ($conditions as $condition) {
            $id = (int)($condition['id'] ?? 0);
            if ($id <= 0) {
                $errors[] = 'Condition without valid id.';
                continue;
            }
            $conditionIds[$id] = true;
            $parentId = $this->nullableInt($condition['condition_id'] ?? null);
            $conditionParents[$id] = $parentId;
        }

        foreach ($conditionParents as $id => $parentId) {
            if ($parentId !== null && !isset($conditionIds[$parentId])) {
                $errors[] = 'Condition ' . $id . ' references unknown parent condition_id ' . $parentId;
            }
            if ($parentId === $id) {
                $errors[] = 'Condition ' . $id . ' cannot reference itself as parent condition.';
            }
        }

        foreach ($this->detectConditionCycles($conditionParents) as $cycle) {
            $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
        }

        foreach ($rules as $rule) {
            $conditionId = (int)($rule['condition_id'] ?? 0);
            if ($conditionId <= 0 || !isset($conditionIds[$conditionId])) {
                $errors[] = 'Rule references unknown condition_id: ' . $conditionId;
            }
            $objectType = (string)($rule['object_type'] ?? '');
            $objectId = (int)($rule['object_id'] ?? 0);
            if ($objectType === 'question' && !isset($questionIds[$objectId])) {
                $errors[] = 'Rule references unknown question object_id: ' . $objectId;
            }
            if ($objectType === 'condition' && !isset($conditionIds[$objectId])) {
                $errors[] = 'Rule references unknown condition object_id: ' . $objectId;
            }
        }

        foreach ($tasks as $task) {
            $resolvedGate = $this->resolveTaskGate($task, $conditionIds);
            $gateTypeRaw = (string)($task['gate_type'] ?? '');
            $gateType = $resolvedGate['gate_type'];
            if (selfserve_task_gate_type::tryFrom($gateTypeRaw) === null && $gateTypeRaw !== '') {
                $warnings[] = 'Task ' . (int)($task['id'] ?? 0) . ' has invalid gate_type `' . $gateTypeRaw . '`, falling back to legacy handling.';
            }

            $gateRefId = $resolvedGate['gate_ref_id'];
            if ($gateType === selfserve_task_gate_type::ALWAYS) {
                continue;
            }
            if ($gateRefId === null) {
                $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' requires gate_ref_id for gate_type ' . $gateType->value;
                continue;
            }
            if ($gateType === selfserve_task_gate_type::CONDITION && !isset($conditionIds[$gateRefId])) {
                $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown condition gate_ref_id ' . $gateRefId;
            }
            if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
                $errors[] = 'Task ' . (int)($task['id'] ?? 0) . ' references unknown question gate_ref_id ' . $gateRefId;
            }
        }

        return [
            'valid' => $errors === [],
            'errors' => $errors,
            'warnings' => $warnings,
            'stats' => [
                'questions' => count($questions),
                'conditions' => count($conditions),
                'rules' => count($rules),
                'tasks' => count($tasks),
                'actions' => count($actions),
            ],
            'validated_at' => date('c'),
        ];
    }

    /**
     * @param array<string,mixed> $config
     * @return array<string,mixed>
     */
    protected function validateV2Config(array $config): array
    {
        $errors = [];
        $warnings = [];

        foreach ((array)($config['migration_issues'] ?? []) as $issue) {
            if (is_array($issue)) {
                $errors[] = (string)($issue['message'] ?? 'Migration issue detected.');
            } else {
                $errors[] = (string)$issue;
            }
        }

        $questions = is_array($config['questions'] ?? null) ? array_values((array)$config['questions']) : [];
        $conditions = is_array($config['conditions'] ?? null) ? array_values((array)$config['conditions']) : [];
        $tasks = is_array($config['tasks'] ?? null) ? array_values((array)$config['tasks']) : [];
        $actions = is_array($config['actions'] ?? null) ? array_values((array)$config['actions']) : [];

        $questionIds = [];
        foreach ($questions as $question) {
            $id = (int)($question['id'] ?? 0);
            if ($id <= 0) {
                $errors[] = 'Question without valid id.';
                continue;
            }
            $questionIds[$id] = true;
        }

        $conditionIds = [];
        $conditionParents = [];
        foreach ($conditions as $condition) {
            $id = (int)($condition['id'] ?? 0);
            if ($id <= 0) {
                $errors[] = 'Condition without valid id.';
                continue;
            }
            $conditionIds[$id] = true;
            $conditionParents[$id] = $this->nullableInt($condition['condition_id'] ?? null);
        }

        $usedConditionIds = [];
        $conditionEdges = [];
        $conditionHasPredicate = [];
        foreach ($conditions as $condition) {
            $conditionId = (int)($condition['id'] ?? 0);
            if ($conditionId <= 0) {
                continue;
            }
            $parentId = $conditionParents[$conditionId] ?? null;
            if ($parentId !== null) {
                if (!isset($conditionIds[$parentId])) {
                    $errors[] = 'Condition ' . $conditionId . ' references unknown parent condition_id ' . $parentId;
                }
                if ($parentId === $conditionId) {
                    $errors[] = 'Condition ' . $conditionId . ' cannot reference itself as parent condition.';
                }
                $conditionEdges[$conditionId][] = $parentId;
            }

            $expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : $this->emptyV2Expression();
            $expressionValidation = $this->validateExpressionNode(
                $expression,
                $conditionId,
                $questionIds,
                $conditionIds,
                $usedConditionIds,
                $conditionEdges,
            );
            $conditionHasPredicate[$conditionId] = $expressionValidation['has_predicate'];
            foreach ($expressionValidation['errors'] as $message) {
                $errors[] = $message;
            }
        }

        foreach ($questions as $question) {
            $conditionId = $this->nullableInt($question['condition_id'] ?? null);
            if ($conditionId === null) {
                continue;
            }
            $usedConditionIds[$conditionId] = true;
            if (!isset($conditionIds[$conditionId])) {
                $errors[] = 'Question ' . (int)($question['id'] ?? 0) . ' references unknown visibility condition_id ' . $conditionId;
            }
        }

        foreach ($tasks as $task) {
            $taskId = (int)($task['id'] ?? 0);
            $gateTypeRaw = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
            $gateType = selfserve_task_gate_type::tryFrom($gateTypeRaw);
            if ($gateType === null) {
                $errors[] = 'Task ' . $taskId . ' has invalid gate_type `' . $gateTypeRaw . '`.';
                continue;
            }

            $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
            if ($gateType === selfserve_task_gate_type::ALWAYS) {
                continue;
            }
            if ($gateRefId === null) {
                $errors[] = 'Task ' . $taskId . ' requires gate_ref_id for gate_type ' . $gateType->value;
                continue;
            }
            if ($gateType === selfserve_task_gate_type::CONDITION) {
                $usedConditionIds[$gateRefId] = true;
                if (!isset($conditionIds[$gateRefId])) {
                    $errors[] = 'Task ' . $taskId . ' references unknown condition gate_ref_id ' . $gateRefId;
                }
            }
            if ($gateType === selfserve_task_gate_type::QUESTION && !isset($questionIds[$gateRefId])) {
                $errors[] = 'Task ' . $taskId . ' references unknown question gate_ref_id ' . $gateRefId;
            }
        }

        $actionIds = [];
        foreach ($actions as $action) {
            if (!is_array($action)) {
                $errors[] = 'Action has invalid payload.';
                continue;
            }
            $actionId = (int)($action['id'] ?? 0);
            if ($actionId <= 0) {
                $errors[] = 'Action without valid id.';
                continue;
            }
            if (isset($actionIds[$actionId])) {
                $errors[] = 'Duplicate action id ' . $actionId . '.';
            }
            $actionIds[$actionId] = true;

            $event = strtolower(trim((string)($action['event'] ?? '')));
            if (!in_array($event, selfserve_studio_actions::events(), true)) {
                $errors[] = 'Action ' . $actionId . ' has invalid event `' . $event . '`.';
            }

            $washMode = strtolower(trim((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)));
            if (!in_array($washMode, selfserve_studio_actions::washModes(), true)) {
                $errors[] = 'Action ' . $actionId . ' has invalid wash_mode `' . $washMode . '`.';
            }
            if ($event === selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED && $washMode === selfserve_studio_actions::MODE_MANUAL) {
                $warnings[] = 'Action ' . $actionId . ' uses manual mode for the machine-start event and will never run.';
            }

            $operation = strtolower(trim((string)($action['operation'] ?? '')));
            if (!in_array($operation, selfserve_studio_actions::operations(), true)) {
                $errors[] = 'Action ' . $actionId . ' has invalid operation `' . $operation . '`.';
            }
            if (selfserve_studio_actions::isRelayOperation($operation) && !array_key_exists('relay_state', $action)) {
                $errors[] = 'Action ' . $actionId . ' requires relay_state for operation ' . $operation . '.';
            }

            $options = is_array($action['options'] ?? null) ? (array)$action['options'] : [];
            $failurePolicy = strtolower(trim((string)($options['failure_policy'] ?? selfserve_studio_actions::FAILURE_CONTINUE)));
            if (!in_array($failurePolicy, selfserve_studio_actions::failurePolicies(), true)) {
                $errors[] = 'Action ' . $actionId . ' has invalid failure_policy `' . $failurePolicy . '`.';
            }

            $conditionId = $this->nullableInt($action['condition_id'] ?? null);
            if ($conditionId !== null) {
                $usedConditionIds[$conditionId] = true;
                if (!isset($conditionIds[$conditionId])) {
                    $errors[] = 'Action ' . $actionId . ' references unknown condition_id ' . $conditionId . '.';
                }
            }
        }

        foreach ($usedConditionIds as $conditionId => $_used) {
            if (isset($conditionIds[(int)$conditionId]) && (($conditionHasPredicate[(int)$conditionId] ?? false) !== true)) {
                $errors[] = 'Condition ' . (int)$conditionId . ' is used but has an empty expression.';
            }
        }

        foreach ($this->detectDirectedConditionCycles($conditionEdges) as $cycle) {
            $errors[] = 'Condition cycle detected: ' . implode(' -> ', $cycle);
        }

        return [
            'valid' => $errors === [],
            'errors' => array_values(array_unique($errors)),
            'warnings' => $warnings,
            'stats' => [
                'schema_version' => self::SCHEMA_VERSION_V2,
                'questions' => count($questions),
                'conditions' => count($conditions),
                'rules' => 0,
                'tasks' => count($tasks),
                'actions' => count($actions),
            ],
            'validated_at' => date('c'),
        ];
    }

    /**
     * @param array<string,mixed> $expression
     * @param array<int,bool> $questionIds
     * @param array<int,bool> $conditionIds
     * @param array<int,bool> $usedConditionIds
     * @param array<int,array<int,int>> $conditionEdges
     * @return array{errors:array<int,string>,has_predicate:bool}
     */
    protected function validateExpressionNode(array $expression, int $ownerConditionId, array $questionIds, array $conditionIds, array &$usedConditionIds, array &$conditionEdges): array
    {
        $errors = [];
        $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
        if ($type === 'predicate') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
            $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? ''));
            if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
                $errors[] = 'Condition ' . $ownerConditionId . ' has invalid predicate operator `' . $operator . '`.';
            }
            if ($subjectType === 'question') {
                if ($subjectId <= 0 || !isset($questionIds[$subjectId])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question predicate subject_id ' . $subjectId;
                }
            } elseif ($subjectType === 'condition') {
                $usedConditionIds[$subjectId] = true;
                $conditionEdges[$ownerConditionId][] = $subjectId;
                if ($subjectId === $ownerConditionId) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in an expression.';
                }
                if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition predicate subject_id ' . $subjectId;
                }
            } else {
                $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported predicate subject_type `' . $subjectType . '`.';
            }

            return [
                'errors' => $errors,
                'has_predicate' => true,
            ];
        }

        if (in_array($type, ['branch', 'if', 'if_else'], true)) {
            $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : [];
            if ($branches === []) {
                $errors[] = 'Condition ' . $ownerConditionId . ' has an empty if/else expression.';
            }

            $hasPredicate = false;
            foreach ($branches as $index => $branch) {
                if (!is_array($branch)) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else clause.';
                    continue;
                }

                $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if')));
                $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true);
                if (!$isElse) {
                    if (!is_array($branch['when'] ?? null)) {
                        $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a when expression.';
                    } else {
                        $whenValidation = $this->validateExpressionNode((array)$branch['when'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
                        $hasPredicate = $hasPredicate || $whenValidation['has_predicate'];
                        foreach ($whenValidation['errors'] as $message) {
                            $errors[] = $message;
                        }
                    }
                }

                if (!is_array($branch['then'] ?? null)) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has an if/else clause without a then expression.';
                    continue;
                }

                $thenValidation = $this->validateExpressionNode((array)$branch['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
                $hasPredicate = $hasPredicate || $thenValidation['has_predicate'];
                foreach ($thenValidation['errors'] as $message) {
                    $errors[] = $message;
                }
            }

            if (array_key_exists('default', $expression)) {
                if (!is_array($expression['default'])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid if/else default expression.';
                } else {
                    $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
                    $hasPredicate = $hasPredicate || $defaultValidation['has_predicate'];
                    foreach ($defaultValidation['errors'] as $message) {
                        $errors[] = $message;
                    }
                }
            }

            return [
                'errors' => $errors,
                'has_predicate' => $hasPredicate,
            ];
        }

        if ($type === 'case') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
            if ($subjectType === 'question') {
                if ($subjectId <= 0 || !isset($questionIds[$subjectId])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' references unknown question case subject_id ' . $subjectId;
                }
            } elseif ($subjectType === 'condition') {
                $usedConditionIds[$subjectId] = true;
                $conditionEdges[$ownerConditionId][] = $subjectId;
                if ($subjectId === $ownerConditionId) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' cannot reference itself in a case expression.';
                }
                if ($subjectId <= 0 || !isset($conditionIds[$subjectId])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' references unknown condition case subject_id ' . $subjectId;
                }
            } else {
                $errors[] = 'Condition ' . $ownerConditionId . ' has unsupported case subject_type `' . $subjectType . '`.';
            }

            $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : [];
            if ($cases === []) {
                $errors[] = 'Condition ' . $ownerConditionId . ' has an empty case expression.';
            }

            $hasPredicate = false;
            foreach ($cases as $case) {
                if (!is_array($case)) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case clause.';
                    continue;
                }
                if (!array_key_exists('value', $case)) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a value.';
                }
                if (!is_array($case['then'] ?? null)) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has a case clause without a then expression.';
                    continue;
                }

                $thenValidation = $this->validateExpressionNode((array)$case['then'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
                $hasPredicate = $hasPredicate || $thenValidation['has_predicate'];
                foreach ($thenValidation['errors'] as $message) {
                    $errors[] = $message;
                }
            }

            if (array_key_exists('default', $expression)) {
                if (!is_array($expression['default'])) {
                    $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid case default expression.';
                } else {
                    $defaultValidation = $this->validateExpressionNode((array)$expression['default'], $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
                    $hasPredicate = $hasPredicate || $defaultValidation['has_predicate'];
                    foreach ($defaultValidation['errors'] as $message) {
                        $errors[] = $message;
                    }
                }
            }

            return [
                'errors' => $errors,
                'has_predicate' => $hasPredicate,
            ];
        }

        $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
        if (!in_array($operator, ['ALL', 'ANY'], true)) {
            $errors[] = 'Condition ' . $ownerConditionId . ' has invalid group operator `' . $operator . '`.';
        }

        $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
        $hasPredicate = false;
        foreach ($children as $child) {
            if (!is_array($child)) {
                $errors[] = 'Condition ' . $ownerConditionId . ' has an invalid expression child.';
                continue;
            }
            $childValidation = $this->validateExpressionNode((array)$child, $ownerConditionId, $questionIds, $conditionIds, $usedConditionIds, $conditionEdges);
            $hasPredicate = $hasPredicate || $childValidation['has_predicate'];
            foreach ($childValidation['errors'] as $message) {
                $errors[] = $message;
            }
        }

        return [
            'errors' => $errors,
            'has_predicate' => $hasPredicate,
        ];
    }

    protected function createDraftFromConfig(int $departmentId, array $config, ?int $sourceVersionId, ?int $createdBy): void
    {
        // Remove stale drafts first.
        $this->deleteAllDrafts($departmentId);

        if (!$this->isV2Config($config)) {
            $config = $this->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
        }

        $validation = $this->validateConfig($config);
        $latestVersionNumber = $this->getLatestVersionNumber($departmentId);
        (new selfserve_config_versions_o())->add(
            $departmentId,
            self::STATUS_DRAFT,
            $latestVersionNumber + 1,
            $config,
            $validation,
            $sourceVersionId,
            $createdBy,
            null,
        );
    }

    protected function archivePublishedVersions(int $departmentId): void
    {
        global $db;
        $departmentId = (int)$departmentId;
        $sql = "UPDATE selfserve_config_versions
                SET status = '" . self::STATUS_ARCHIVED . "'
                WHERE department_id = $departmentId
                  AND status = '" . self::STATUS_PUBLISHED . "'
                  AND deleted_at IS NULL";
        $db->query($sql);
    }

    protected function deleteAllDrafts(int $departmentId): void
    {
        global $db;
        $departmentId = (int)$departmentId;
        $sql = "UPDATE selfserve_config_versions
                SET deleted_at = NOW()
                WHERE department_id = $departmentId
                  AND status = '" . self::STATUS_DRAFT . "'
                  AND deleted_at IS NULL";
        $db->query($sql);
    }

    protected function getLatestVersionNumber(int $departmentId): int
    {
        global $db;
        $departmentId = (int)$departmentId;
        $sql = "SELECT MAX(version_number) AS latest_version
                FROM selfserve_config_versions
                WHERE department_id = $departmentId
                  AND deleted_at IS NULL";
        $result = $db->query($sql);
        $row = $db->fetch_assoc($result);
        return (int)($row['latest_version'] ?? 0);
    }

    /**
     * @return array<int>
     */
    protected function getKnownDepartmentIdsForSync(): array
    {
        $departmentRows = (new departments_o())->getFieldsWhere([
            'deleted_at' => null,
        ], ['id']);
        $ids = array_map(static fn(array $row): int => (int)$row['id'], $departmentRows);

        if ($ids === []) {
            return [];
        }

        return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
    }

    protected function nullableInt(mixed $value): ?int
    {
        if ($value === null || $value === '' || $value === 'null') {
            return null;
        }

        $intValue = (int)$value;
        return $intValue <= 0 ? null : $intValue;
    }

    /**
     * @param array<int,int|null> $parents
     * @return array<int,array<int,int>>
     */
    protected function detectConditionCycles(array $parents): array
    {
        $cycles = [];
        $seenCycleKeys = [];

        foreach (array_keys($parents) as $startId) {
            $path = [];
            $indexById = [];
            $currentId = (int)$startId;

            while ($currentId > 0 && array_key_exists($currentId, $parents)) {
                if (isset($indexById[$currentId])) {
                    $cycle = array_slice($path, $indexById[$currentId]);
                    $cycle[] = $currentId;
                    $keyNodes = $cycle;
                    sort($keyNodes);
                    $key = implode(':', $keyNodes);
                    if (!isset($seenCycleKeys[$key])) {
                        $seenCycleKeys[$key] = true;
                        $cycles[] = $cycle;
                    }
                    break;
                }

                $indexById[$currentId] = count($path);
                $path[] = $currentId;
                $currentId = (int)($parents[$currentId] ?? 0);
            }
        }

        return $cycles;
    }

    /**
     * @param array<int,array<string,mixed>> $rules
     * @param array<int,array<string,mixed>> $migrationIssues
     * @return array<string,mixed>
     */
    protected function migrateLegacyRulesToExpression(int $conditionId, array $rules, array &$migrationIssues): array
    {
        $allChildren = [];
        $anyChildren = [];

        foreach ($rules as $rule) {
            $predicate = $this->legacyRuleToPredicate($conditionId, $rule, $migrationIssues);
            if ($predicate === null) {
                continue;
            }

            if (strtoupper((string)($rule['type'] ?? '')) === 'IS_TRUE_OR_ANY_TRUE') {
                $anyChildren[] = $predicate;
            } else {
                $allChildren[] = $predicate;
            }
        }

        if ($anyChildren !== []) {
            $allChildren[] = [
                'type' => 'group',
                'operator' => 'ANY',
                'children' => $anyChildren,
            ];
        }

        return [
            'type' => 'group',
            'operator' => 'ALL',
            'children' => $allChildren,
        ];
    }

    /**
     * @param array<string,mixed> $rule
     * @param array<int,array<string,mixed>> $migrationIssues
     * @return array<string,mixed>|null
     */
    protected function legacyRuleToPredicate(int $conditionId, array $rule, array &$migrationIssues): ?array
    {
        $ruleId = (int)($rule['id'] ?? 0);
        $objectType = strtolower((string)($rule['object_type'] ?? ''));
        if (!in_array($objectType, ['question', 'condition'], true)) {
            $migrationIssues[] = [
                'severity' => 'error',
                'condition_id' => $conditionId,
                'rule_id' => $ruleId,
                'message' => 'Rule ' . $ruleId . ' uses unsupported object_type `' . $objectType . '` and cannot be migrated to v2.',
            ];
            return null;
        }

        $legacyType = strtoupper((string)($rule['type'] ?? ''));
        $operator = $legacyType === 'IS_TRUE_OR_ANY_TRUE' ? 'IS_TRUE' : $legacyType;
        if (!in_array($operator, self::V2_PREDICATE_OPERATORS, true)) {
            $migrationIssues[] = [
                'severity' => 'error',
                'condition_id' => $conditionId,
                'rule_id' => $ruleId,
                'message' => 'Rule ' . $ruleId . ' uses unsupported type `' . $legacyType . '` and cannot be migrated to v2.',
            ];
            return null;
        }

        return [
            'type' => 'predicate',
            'subject_type' => $objectType,
            'subject_id' => (int)($rule['object_id'] ?? 0),
            'operator' => $operator,
            'legacy_rule_id' => $ruleId > 0 ? $ruleId : null,
        ];
    }

    /**
     * @param array<string,mixed> $config
     * @return array<string,mixed>
     */
    protected function normalizeV2Config(array $config): array
    {
        $config['schema_version'] = self::SCHEMA_VERSION_V2;
        $config['questions'] = array_values((array)($config['questions'] ?? []));
        $config['conditions'] = array_values(array_map(function ($condition): array {
            $condition = is_array($condition) ? $condition : [];
            if (!is_array($condition['expression'] ?? null)) {
                $condition['expression'] = $this->emptyV2Expression();
            }
            return $condition;
        }, (array)($config['conditions'] ?? [])));
        $config['rules'] = [];
        $conditionIds = [];
        foreach ($config['conditions'] as $condition) {
            $id = (int)($condition['id'] ?? 0);
            if ($id > 0) {
                $conditionIds[$id] = true;
            }
        }
        $config['tasks'] = array_values(array_map(
            fn($task): array => $this->normalizeTaskGate(is_array($task) ? (array)$task : [], $conditionIds),
            (array)($config['tasks'] ?? [])
        ));
        $config['actions'] = array_values(array_map(
            static fn($action): array => selfserve_studio_actions::normalize(is_array($action) ? (array)$action : []),
            (array)($config['actions'] ?? [])
        ));
        $config['v2_meta'] = is_array($config['v2_meta'] ?? null) ? (array)$config['v2_meta'] : [];
        $config['v2_meta']['next_ids'] = $this->nextIdsForConfig($config);
        return $config;
    }

    /**
     * @param array<string,mixed> $task
     * @param array<int,bool|int> $conditionIds
     * @return array<string,mixed>
     */
    protected function normalizeTaskGate(array $task, array $conditionIds): array
    {
        $resolvedGate = $this->resolveTaskGate($task, $conditionIds);
        $task['gate_type'] = $resolvedGate['gate_type']->value;
        $task['gate_ref_id'] = $resolvedGate['gate_ref_id'];

        $task['condition_id'] = $resolvedGate['gate_type'] === selfserve_task_gate_type::ALWAYS
            ? null
            : $resolvedGate['gate_ref_id'];

        return $task;
    }

    /**
     * @param array<string,mixed> $task
     * @param array<int,bool|int> $conditionIds
     * @return array{gate_type:selfserve_task_gate_type,gate_ref_id:int|null}
     */
    protected function resolveTaskGate(array $task, array $conditionIds): array
    {
        $gateType = selfserve_task_gate_type::tryFrom(strtoupper(trim((string)($task['gate_type'] ?? ''))));
        $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
        $legacyGateId = $this->nullableInt($task['condition_id'] ?? null);

        if (
            $gateType === selfserve_task_gate_type::CONDITION
            || $gateType === selfserve_task_gate_type::QUESTION
        ) {
            return [
                'gate_type' => $gateType,
                'gate_ref_id' => $gateRefId ?? $legacyGateId,
            ];
        }

        $shouldInferLegacyGate = $gateType === null
            || (
                $gateType === selfserve_task_gate_type::ALWAYS
                && $gateRefId === null
                && $legacyGateId !== null
            );

        if ($shouldInferLegacyGate) {
            $fallbackGateId = $gateRefId ?? $legacyGateId;
            if ($fallbackGateId === null) {
                return [
                    'gate_type' => selfserve_task_gate_type::ALWAYS,
                    'gate_ref_id' => null,
                ];
            }

            return [
                'gate_type' => $this->containsIntegerId($conditionIds, $fallbackGateId)
                    ? selfserve_task_gate_type::CONDITION
                    : selfserve_task_gate_type::QUESTION,
                'gate_ref_id' => $fallbackGateId,
            ];
        }

        return [
            'gate_type' => selfserve_task_gate_type::ALWAYS,
            'gate_ref_id' => null,
        ];
    }

    /**
     * @param array<int,bool|int> $ids
     */
    protected function containsIntegerId(array $ids, int $id): bool
    {
        return isset($ids[$id]) || in_array($id, $ids, true);
    }

    /**
     * @param array<string,mixed> $config
     * @return array<string,int>
     */
    protected function nextIdsForConfig(array $config): array
    {
        $next = [];
        foreach (['questions' => 'question', 'conditions' => 'condition', 'tasks' => 'task', 'actions' => 'action'] as $key => $name) {
            $max = 0;
            foreach ((array)($config[$key] ?? []) as $row) {
                if (is_array($row)) {
                    $max = max($max, (int)($row['id'] ?? 0));
                }
            }
            $next[$name] = $max + 1;
        }
        return $next;
    }

    /**
     * @return array<string,mixed>
     */
    protected function emptyV2Expression(): array
    {
        return [
            'type' => 'group',
            'operator' => 'ALL',
            'children' => [],
        ];
    }

    /**
     * @param array<int,array<int,int>> $edges
     * @return array<int,array<int,int>>
     */
    protected function detectDirectedConditionCycles(array $edges): array
    {
        $cycles = [];
        $visiting = [];
        $visited = [];
        $stack = [];

        $walk = function (int $conditionId) use (&$walk, &$cycles, &$visiting, &$visited, &$stack, $edges): void {
            if (isset($visited[$conditionId])) {
                return;
            }
            if (isset($visiting[$conditionId])) {
                $start = array_search($conditionId, $stack, true);
                $cycle = array_slice($stack, $start === false ? 0 : (int)$start);
                $cycle[] = $conditionId;
                $cycles[] = $cycle;
                return;
            }

            $visiting[$conditionId] = true;
            $stack[] = $conditionId;
            foreach (array_unique(array_map('intval', (array)($edges[$conditionId] ?? []))) as $nextId) {
                if ($nextId > 0) {
                    $walk($nextId);
                }
            }
            array_pop($stack);
            unset($visiting[$conditionId]);
            $visited[$conditionId] = true;
        };

        foreach (array_keys($edges) as $conditionId) {
            $walk((int)$conditionId);
        }

        return $cycles;
    }
}
