new_simple_functions.php

Available 11,070 lines 261.8 KB
Read-only source view
<?php
declare(strict_types=1); 

/**
 * Assign the three root radicals from the end of the analyzed stem.
 *
 * Structural prefix letters such as imperfect ي and Form V/VI ت
 * must already be identified or skipped here.
 *
 * A cluster containing shadda represents a repeated radical, but the
 * repeated letter must occupy only its correct root position.
 */
function ace_assign_root_positions(array $analysis): array
{
    $clusters = $analysis['structural_clusters']
        ?? $analysis['clusters']
        ?? array();

    $analysis['r0'] = '';
    $analysis['r1'] = '';
    $analysis['r2'] = '';

    $analysis['v1'] = '';
    $analysis['v2'] = '';

    $analysis['r0_marks'] = '';
    $analysis['r1_marks'] = '';
    $analysis['r2_marks'] = '';

    $analysis['r1_repeat'] = false;
    $analysis['r2_repeat'] = false;

    if (count($clusters) === 0) {
        $analysis['root'] = '';

        return $analysis;
    }

    /*
     * Remove clusters that cannot be root radicals.
     *
     * ي = imperfect person marker
     * ت = Form V/VI structural marker when it follows the person marker
     */
    $workingClusters = array_values($clusters);

    if (
        isset($workingClusters[0]['letter'])
        && in_array(
            $workingClusters[0]['letter'],
            array('ي', 'ت', 'أ', 'ا', 'ن'),
            true
        )
    ) {
        array_shift($workingClusters);
    }

    if (
        isset($workingClusters[0]['letter'])
        && $workingClusters[0]['letter'] === 'ت'
        && count($workingClusters) >= 4
    ) {
        array_shift($workingClusters);
    }

    /*
     * Assign r2 first.
     *
     * The final consonantal cluster is normally the third radical.
     */
    $r2Index = ace_find_previous_radical_cluster(
        $workingClusters,
        count($workingClusters) - 1
    );

    if ($r2Index === null) {
        $analysis['root'] = '';

        return $analysis;
    }

    $r2Cluster = $workingClusters[$r2Index];

    $analysis['r2'] = $r2Cluster['letter'] ?? '';
    $analysis['r2_marks'] = $r2Cluster['marks'] ?? '';

    /*
     * Assign r1 before r2.
     *
     * Skip long structural alif in patterns such as Form VI:
     * يَتَدَارَسُ → د ا ر س
     *
     * The alif is v1, not a radical.
     */
    $r1Index = ace_find_previous_radical_cluster(
        $workingClusters,
        $r2Index - 1
    );

    if ($r1Index === null) {
        $analysis['root'] = $analysis['r2'];

        return $analysis;
    }

    $r1Cluster = $workingClusters[$r1Index];

    $analysis['r1'] = $r1Cluster['letter'] ?? '';
    $analysis['r1_marks'] = $r1Cluster['marks'] ?? '';

    /*
     * A shadda on r1 indicates Form II or Form V middle-radical
     * repetition. It does not mean that r0 is also the same letter.
     */
    if (ace_cluster_has_shadda($r1Cluster)) {
        $analysis['r1_repeat'] = true;
    }

    /*
     * Assign r0 before r1.
     *
     * If a long alif occurs between r0 and r1, record it as v1
     * and continue backward to obtain the real first radical.
     */
    $r0Index = ace_find_previous_radical_cluster(
        $workingClusters,
        $r1Index - 1
    );

    if ($r0Index !== null) {
        $r0Cluster = $workingClusters[$r0Index];

        $analysis['r0'] = $r0Cluster['letter'] ?? '';
        $analysis['r0_marks'] = $r0Cluster['marks'] ?? '';
    }

    /*
     * Detect a long alif between r0 and r1.
     */
    if (
        $r0Index !== null
        && $r1Index - $r0Index > 1
    ) {
        for ($index = $r0Index + 1; $index < $r1Index; $index++) {
            $letter = $workingClusters[$index]['letter'] ?? '';

            if (in_array($letter, array('ا', 'ى'), true)) {
                $analysis['v1'] = $letter;
                break;
            }
        }
    }

    /*
     * Detect final-radical repetition, used especially by Form IX.
     */
    if (ace_cluster_has_shadda($r2Cluster)) {
        $analysis['r2_repeat'] = true;
    }

    /*
     * Construct the root in root order only after r0, r1 and r2
     * have been assigned.
     */
    $rootParts = array(
        $analysis['r0'],
        $analysis['r1'],
        $analysis['r2'],
    );

    $nonEmptyRootParts = array();

    foreach ($rootParts as $rootPart) {
        if ($rootPart !== '') {
            $nonEmptyRootParts[] = $rootPart;
        }
    }

    $analysis['root'] = implode('', $nonEmptyRootParts);

    return $analysis;
}

/**
 * Find the preceding cluster that may function as a root radical.
 *
 * Long alif is normally a pattern vowel rather than an independent
 * radical in the current strong-root Form V/VI analysis.
 */
function ace_find_previous_radical_cluster(
    array $clusters,
    int $startIndex
): ?int {
    for ($index = $startIndex; $index >= 0; $index--) {
        $letter = $clusters[$index]['letter'] ?? '';

        if ($letter === '') {
            continue;
        }

        /*
         * Do not assign a medial long alif as a radical.
         *
         * Weak-root restoration can later reintroduce و or ي when
         * morphological evidence requires it.
         */
        if (in_array($letter, array('ا', 'ى', 'ٰ'), true)) {
            continue;
        }

        return $index;
    }

    return null;
}

/**
 * Determine whether a cluster contains shadda.
 */
function ace_cluster_has_shadda(array $cluster): bool
{
    $marks = $cluster['marks'] ?? '';

    return mb_strpos($marks, 'ّ') !== false;
}
/**
 * Remove Arabic diacritical marks while preserving Arabic letters.
 */
if (!function_exists('ace_remove_arabic_marks')) {
    function ace_remove_arabic_marks(string $text): string
    {
        // Remove Qur'anic annotation marks.
        $text = preg_replace('/[\x{0610}-\x{061A}]/u', '', $text);

        // Remove Arabic harakat and common combining marks:
        // fathatan, dammatan, kasratan, fatha, damma, kasra,
        // shadda, sukun, superscript alif, and related marks.
        $text = preg_replace('/[\x{064B}-\x{065F}]/u', '', $text);

        // Remove superscript alif and additional Arabic combining marks.
        $text = preg_replace('/[\x{0670}\x{06D6}-\x{06ED}]/u', '', $text);

        return $text ?? '';
    }
}

/**
 * Prevent final weak radicals from being mistaken for suffixes.
 */
function ace_is_valid_suffix_removal(
    string $token,
    string $suffix,
    string $candidateStem,
    array $suffixEntry
): bool {
    $plainToken = ace_remove_arabic_marks($token);
    $plainStem  = ace_remove_arabic_marks($candidateStem);

    /*
     * A final ي may be:
     *   1. a genuine 2fs suffix, or
     *   2. the final radical of a defective verb.
     *
     * Do not remove it when the remaining imperfect stem would
     * contain only the person marker plus one radical.
     *
     * Example:
     *   يَقِي → removing ي gives يَقِ / يق
     * This destroys the defective radical of root و-ق-ي.
     */
    if ($suffix === 'ي') {
        $letters = preg_split('//u', $plainStem, -1, PREG_SPLIT_NO_EMPTY);

        $initial = $letters[0] ?? '';
        $isImperfectInitial = in_array(
            $initial,
            ['ي', 'ت', 'أ', 'ن'],
            true
        );

        if ($isImperfectInitial && count($letters) <= 2) {
            return false;
        }
    }

    return true;
}
/**
 * Generate weak-root candidates when only two radicals remain.
 *
 * The missing position must be established structurally before
 * candidates are generated.
 *
 * @return array<int, string>
 */
function ace_generate_missing_weak_candidates(
    string $r0,
    string $r1,
    string $r2,
    string $missingPosition
): array {
    $weakRadicals = ['و', 'ي'];
    $candidates = [];

    foreach ($weakRadicals as $weakRadical) {
        if ($missingPosition === 'r1') {
            if ($r0 !== '' && $r2 !== '') {
                $candidates[] = $r0 . $weakRadical . $r2;
            }
        } elseif ($missingPosition === 'r2') {
            if ($r0 !== '' && $r1 !== '') {
                $candidates[] = $r0 . $r1 . $weakRadical;
            }
        } elseif ($missingPosition === 'r0') {
            if ($r1 !== '' && $r2 !== '') {
                $candidates[] = $weakRadical . $r1 . $r2;
            }
        }
    }

    return array_values(array_unique($candidates));
}
/*
 * ============================================================
 * ARABIC UNICODE CONSTANTS
 * ============================================================
 */
const ACE_FATHATAN    = "\u{064B}"; // ً
const ACE_DAMMATAN    = "\u{064C}"; // ٌ
const ACE_KASRATAN    = "\u{064D}"; // ٍ
const ACE_FATHA       = "\u{064E}"; // َ
const ACE_DAMMA       = "\u{064F}"; // ُ
const ACE_KASRA       = "\u{0650}"; // ِ
const ACE_SHADDA      = "\u{0651}"; // ّ
const ACE_SUKUN       = "\u{0652}"; // ْ
const ACE_DAGGER_ALIF = "\u{0670}"; // ٰ
const ACE_TATWEEL     = "\u{0640}"; // ـ
/**
 * Split a UTF-8 string into Unicode characters.
 *
 * @return list<string>
 */
/**
 * Run one Arabic token through the complete reverse-engine pipeline.
 */
/**
 * Safely convert any ACE value to displayable text.
 */
/**
 * Return the visible text of one structural cluster.
 */
function ace_cluster_to_string(mixed $cluster): string
{
    if (is_string($cluster)) {
        return $cluster;
    }

    if (!is_array($cluster)) {
        return '';
    }

    if (isset($cluster['text']) && is_scalar($cluster['text'])) {
        return (string) $cluster['text'];
    }

    $letter = isset($cluster['letter']) && is_scalar($cluster['letter'])
        ? (string) $cluster['letter']
        : '';

    $marks = $cluster['marks'] ?? [];

if (is_array($marks)) {
    $marks = implode('', array_map(
        function ($mark) {
            return is_scalar($mark) ? (string) $mark : '';
        },
        $marks
    ));
} elseif (!is_scalar($marks)) {
    $marks = '';
}
    return $letter . (string) $marks;
}

/**
 * ============================================================
 * STEM-LENGTH FORM CANDIDATES
 * ============================================================
 *
 * Determine the effective stem length after suffix removal and
 * provide form candidates that are structurally compatible with
 * that length.
 *
 * Length rules:
 * - Arabic vowel marks do not count as letters.
 * - Shadda represents a doubled consonant and therefore adds one
 *   structural unit.
 * - Imperfect stems include the imperfect person marker (VN), so
 *   they use a separate length profile.
 *
 * This stage does not replace ace_detect_form(). It supplies a
 * length constraint that later form-detection stages can combine
 * with VN, VM, FS, long-vowel, and repetition evidence.
 *
 * Adds:
 * - stem_length_after_suffix_removal
 * - stem_surface_length
 * - stem_shadda_count
 * - stem_effective_length
 * - stem_length_context
 * - stem_length_form_map
 * - potential_forms_by_stem_length
 * - potential_form_by_stem_length
 * - detected_form_length_supported
 * - length_supported_form_candidates
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_identify_potential_forms_by_stem_length(
    array $analysis
): array {
    $markedStem = isset($analysis['marked_stem'])
        ? (string) $analysis['marked_stem']
        : '';

    $plainStem = isset($analysis['plain_stem'])
        ? (string) $analysis['plain_stem']
        : (
            function_exists('ace_remove_arabic_marks')
                ? ace_remove_arabic_marks($markedStem)
                : preg_replace('/[\\x{064B}-\\x{065F}\\x{0670}]/u', '', $markedStem)
        );

    if (!is_string($plainStem)) {
        $plainStem = '';
    }

    /*
     * Count surface letters after suffix removal.
     */
    $surfaceLength = mb_strlen($plainStem, 'UTF-8');

    /*
     * Count shadda before marks are removed. Each shadda represents
     * one additional consonantal position.
     */
    $shaddaCount = preg_match_all('/\\x{0651}/u', $markedStem, $matches);
    $shaddaCount = is_int($shaddaCount) ? $shaddaCount : 0;

    $effectiveLength = $surfaceLength + $shaddaCount;

    /*
     * Prefer the baseline imperfect-group decision. Fall back to
     * the first plain letter only when that field is unavailable.
     */
    if (array_key_exists('is_imperfect_group', $analysis)) {
        $isImperfect = (bool) $analysis['is_imperfect_group'];
    } else {
        $firstLetter = $plainStem !== ''
            ? mb_substr($plainStem, 0, 1, 'UTF-8')
            : '';

        $isImperfect = in_array(
            $firstLetter,
            array('ي', 'ت', 'أ', 'ن'),
            true
        );
    }

    $context = $isImperfect ? 'imperfect' : 'non_imperfect';

    /*
     * Canonical effective structural lengths for strong triliteral
     * stems after suffix removal.
     *
     * These are candidate constraints, not final form decisions.
     */
    $formLengthMap = $isImperfect
        ? array(
            'I'    => 4,
            'II'   => 5,
            'III'  => 5,
            'IV'   => 4,
            'V'    => 6,
            'VI'   => 6,
            'VII'  => 5,
            'VIII' => 5,
            'IX'   => 5,
            'X'    => 7,
        )
        : array(
            'I'    => 3,
            'II'   => 4,
            'III'  => 4,
            'IV'   => 4,
            'V'    => 5,
            'VI'   => 5,
            'VII'  => 5,
            'VIII' => 5,
            'IX'   => 5,
            'X'    => 6,
        );

    $potentialForms = array();

    foreach ($formLengthMap as $form => $requiredLength) {
        if ($effectiveLength === $requiredLength) {
            $potentialForms[] = $form;
        }
    }

    $analysis['stem_length_after_suffix_removal'] = $effectiveLength;
    $analysis['stem_surface_length'] = $surfaceLength;
    $analysis['stem_shadda_count'] = $shaddaCount;
    $analysis['stem_effective_length'] = $effectiveLength;
    $analysis['stem_length_context'] = $context;
    $analysis['stem_length_form_map'] = $formLengthMap;
    $analysis['potential_forms_by_stem_length'] = $potentialForms;
    $analysis['potential_form_by_stem_length'] = count($potentialForms) === 1
        ? $potentialForms[0]
        : '';

    /*
     * Validate a form already selected by an earlier/later stage.
     * The pipeline calls this function both before form detection
     * and after form-candidate generation.
     */
    $detectedForm = '';

    foreach (array('detected_form', 'form', 'selected_form') as $field) {
        if (isset($analysis[$field]) && is_string($analysis[$field])) {
            $candidate = trim($analysis[$field]);

            if ($candidate !== '') {
                $detectedForm = $candidate;
                break;
            }
        }
    }

    $analysis['detected_form_length_supported'] = $detectedForm === ''
        ? null
        : in_array($detectedForm, $potentialForms, true);

    /*
     * Intersect downstream form candidates with the length filter,
     * without erasing the original candidate collection.
     */
    $existingCandidates = array();

    foreach (array('form_candidates', 'potential_forms') as $field) {
        if (isset($analysis[$field]) && is_array($analysis[$field])) {
            $existingCandidates = $analysis[$field];
            break;
        }
    }

    $normalizedExistingCandidates = array();

    foreach ($existingCandidates as $candidate) {
        if (is_string($candidate) && trim($candidate) !== '') {
            $normalizedExistingCandidates[] = trim($candidate);
            continue;
        }

        if (is_array($candidate)) {
            foreach (array('form', 'name', 'value') as $field) {
                if (
                    isset($candidate[$field])
                    && is_string($candidate[$field])
                    && trim($candidate[$field]) !== ''
                ) {
                    $normalizedExistingCandidates[] = trim(
                        $candidate[$field]
                    );
                    break;
                }
            }
        }
    }

    $analysis['length_supported_form_candidates'] =
        $normalizedExistingCandidates === array()
            ? $potentialForms
            : array_values(
                array_intersect(
                    array_unique($normalizedExistingCandidates),
                    $potentialForms
                )
            );

    return $analysis;
}
/**
 * Run the complete ACE reverse-engine pipeline.
 *
 * Pipeline order:
 *
 * 0. Analyze and validate suffix removal.
 * 1. Establish baseline/display fields.
 * 2. Identify possible forms by stem length.
 * 3. Extract structural stem positions.
 * 4. Extract VN, VM, and form-sign information.
 * 5. Assign root positions and structural features.
 * 6. Continue form and root reconstruction.
 *
 * Driver rules:
 *
 * - Preserve all validated suffix-removal fields.
 * - Run optional stages through protected stage runners.
 * - Preserve accumulated analysis when a stage is missing, throws an
 *   exception, or returns a non-array value.
 * - Merge every valid stage result into the accumulated analysis.
 * - Extract VN, VM, and the form sign before form detection.
 * - Run structural root reconstruction before weak-root reconstruction.
 *
 * @param string               $token
 * @param array<string, mixed> $masterSuffixes
 *
 * @return array<string, mixed>
 */
/*
function ace_run_reverse_engine_pipeline(
    string $token,
    array $masterSuffixes = array()
): array {
    try {
        $analysis = ace_analyze_validated_suffix_removal(
            $token,
            $masterSuffixes
        );
    } catch (\Throwable $exception) {
        $analysis = array(
            'pipeline_errors' => array(
                array(
                    'stage'   => 'ace_analyze_validated_suffix_removal',
                    'message' => $exception->getMessage(),
                ),
            ),
        );
    }
    if (!is_array($analysis)) {
        $analysis = array(
            'pipeline_errors' => array(
                array(
                    'stage'   => 'ace_analyze_validated_suffix_removal',
                    'message' => (
                        'Validated suffix-removal stage did not return an array.'
                    ),
                ),
            ),
        );
    }

    $analysis['pipeline_errors'] =
        isset($analysis['pipeline_errors'])
        && is_array($analysis['pipeline_errors'])
            ? $analysis['pipeline_errors']
            : array();

    $analysis['token'] = isset($analysis['token'])
        ? (string) $analysis['token']
        : $token;

    $analysis['original_token'] = isset($analysis['original_token'])
        ? (string) $analysis['original_token']
        : $token;

    $analysis['token_length'] = isset($analysis['token_length'])
        ? (int) $analysis['token_length']
        : mb_strlen($token, 'UTF-8');

    $analysis['matched_suffix'] = isset($analysis['matched_suffix'])
        ? (string) $analysis['matched_suffix']
        : '';

    $analysis['matched_suffix_entry'] =
        $analysis['matched_suffix_entry'] ?? '';

    $analysis['matched_suffix_length'] =
        isset($analysis['matched_suffix_length'])
            ? (int) $analysis['matched_suffix_length']
            : mb_strlen(
                (string) $analysis['matched_suffix'],
                'UTF-8'
            );

    $analysis['marked_stem'] = isset($analysis['marked_stem'])
        ? (string) $analysis['marked_stem']
        : $token;

    $analysis['token_length_after_suffix_removal'] =
        isset($analysis['token_length_after_suffix_removal'])
            ? (int) $analysis['token_length_after_suffix_removal']
            : mb_strlen(
                (string) $analysis['marked_stem'],
                'UTF-8'
            );

    $analysis['plain_stem'] = isset($analysis['plain_stem'])
        ? (string) $analysis['plain_stem']
        : ace_remove_arabic_marks(
            (string) $analysis['marked_stem']
        );

    $analysis['plain_stem_length'] =
        isset($analysis['plain_stem_length'])
            ? (int) $analysis['plain_stem_length']
            : mb_strlen(
                (string) $analysis['plain_stem'],
                'UTF-8'
            );

    $requiredDisplayFields = array(
        'token' => $analysis['token'],

        'original_token' =>
            $analysis['original_token'],

        'token_length' =>
            $analysis['token_length'],

        'matched_suffix' =>
            $analysis['matched_suffix'],

        'matched_suffix_entry' =>
            $analysis['matched_suffix_entry'],

        'matched_suffix_length' =>
            $analysis['matched_suffix_length'],

        'marked_stem' =>
            $analysis['marked_stem'],

        'token_length_after_suffix_removal' =>
            $analysis['token_length_after_suffix_removal'],

        'plain_stem' =>
            $analysis['plain_stem'],

        'plain_stem_length' =>
            $analysis['plain_stem_length'],
    );

    $analysis = $runAnalysisStage(
        'ace_detect_form',
        $analysis
    );

    $analysis = $runAnalysisStage(
        'ace_build_structure',
        $analysis
    );

    // Stage 12: Extract direct structural candidates.
    $analysis = $runAnalysisStage(
        'ace_extract_root_candidates',
        $analysis
    );

    // Stage 13: Generate additional structural candidates.
    $analysis = $runAnalysisStage(
        'ace_generate_root_candidates',
        $analysis
    );

    // Stage 16: Validate structural candidates.
    $analysis = $runAnalysisStage(
        'ace_validate_root_candidates',
        $analysis
    );

    // Stage 17: Build the unified structural candidate collection.
    $analysis = $runAnalysisStage(
        'ace_build_root_candidates',
        $analysis
    );

    // Stage 18: Select the preliminary structural root.
    $analysis = $runAnalysisStage(
        'ace_select_final_root',
        $analysis
    );

    // Stage 19: Identify structural form candidates.
    $analysis = $runAnalysisStage(
        'ace_identify_form_candidates',
        $analysis
    );

    $analysis = $runAnalysisStage(
        'ace_identify_potential_forms_by_stem_length',
        $analysis
    );

    // Stage 20: Select the best structural root candidate.
    $analysis = $runAnalysisStage(
        'ace_select_best_root_candidate',
        $analysis
    );

    // Stage 21: Detect visible or suspected weak radicals.
    $analysis = $runAnalysisStage(
        'ace_detect_weak_radicals',
        $analysis
    );

    // Stage 22: Resolve assimilated or mithal roots.
    $analysis = $runAnalysisStage(
        'ace_resolve_mithal_roots',
        $analysis
    );

    // Stage 23: Resolve hollow roots.
    $analysis = $runAnalysisStage(
        'ace_resolve_hollow_roots',
        $analysis
    );

    // Stage 24: Resolve defective roots.
    $analysis = $runAnalysisStage(
        'ace_resolve_defective_roots',
        $analysis
    );

    // Stage 25: Resolve hamzated roots.
    $analysis = $runAnalysisStage(
        'ace_resolve_hamzated_roots',
        $analysis
    );

    // Stage 26: Merge all weak-root candidate collections.
    $analysis = $runAnalysisStage(
        'ace_merge_weak_root_candidates',
        $analysis
    );

    // Stage 27: Score the merged weak-root candidates.
    $analysis = $runAnalysisStage(
        'ace_score_weak_root_candidates',
        $analysis
    );

    // Stage 28: Select the final lexical root.
    $analysis = $runAnalysisStage(
        'ace_select_final_lexical_root',
        $analysis
    );

    foreach ($requiredDisplayFields as $field => $value) {
        $analysis[$field] = $value;
    }

    $analysis['letters'] =
        isset($analysis['letters'])
        && is_array($analysis['letters'])
            ? $analysis['letters']
            : array();

    $analysis['structural_clusters'] =
        isset($analysis['structural_clusters'])
        && is_array($analysis['structural_clusters'])
            ? $analysis['structural_clusters']
            : array();

    $analysis['clusters'] =
        isset($analysis['clusters'])
        && is_array($analysis['clusters'])
            ? $analysis['clusters']
            : $analysis['structural_clusters'];

    $analysis['root_candidates'] =
        isset($analysis['root_candidates'])
        && is_array($analysis['root_candidates'])
            ? $analysis['root_candidates']
            : array();

    $analysis['validated_roots'] =
        isset($analysis['validated_roots'])
        && is_array($analysis['validated_roots'])
            ? $analysis['validated_roots']
            : array();

    $analysis['valid_roots'] =
        isset($analysis['valid_roots'])
        && is_array($analysis['valid_roots'])
            ? $analysis['valid_roots']
            : array();

    $analysis['form_candidates'] =
        isset($analysis['form_candidates'])
        && is_array($analysis['form_candidates'])
            ? $analysis['form_candidates']
            : array();

    $analysis['potential_forms_by_stem_length'] =
        isset($analysis['potential_forms_by_stem_length'])
        && is_array($analysis['potential_forms_by_stem_length'])
            ? $analysis['potential_forms_by_stem_length']
            : array();

    $analysis['length_supported_form_candidates'] =
        isset($analysis['length_supported_form_candidates'])
        && is_array($analysis['length_supported_form_candidates'])
            ? $analysis['length_supported_form_candidates']
            : array();

    $analysis['weak_radicals'] =
        isset($analysis['weak_radicals'])
        && is_array($analysis['weak_radicals'])
            ? $analysis['weak_radicals']
            : array();

    $analysis['weak_root_candidates'] =
        isset($analysis['weak_root_candidates'])
        && is_array($analysis['weak_root_candidates'])
            ? $analysis['weak_root_candidates']
            : array();

    $analysis['scored_weak_root_candidates'] =
        isset($analysis['scored_weak_root_candidates'])
        && is_array($analysis['scored_weak_root_candidates'])
            ? $analysis['scored_weak_root_candidates']
            : array();

    $analysis['pipeline_errors'] =
        isset($analysis['pipeline_errors'])
        && is_array($analysis['pipeline_errors'])
            ? $analysis['pipeline_errors']
            : array();

    $analysis['root'] = isset($analysis['root'])
        ? (string) $analysis['root']
        : '';

    $analysis['final_root'] = isset($analysis['final_root'])
        ? (string) $analysis['final_root']
        : '';

    $analysis['selected_root'] = isset($analysis['selected_root'])
        ? (string) $analysis['selected_root']
        : '';

    $analysis['final_lexical_root'] =
        isset($analysis['final_lexical_root'])
            ? (string) $analysis['final_lexical_root']
            : '';

    return $analysis;
}
*/
/**
 * ============================================================
 * FUNCTION 11
 * Build Normalized Structure
 * ============================================================
 *
 * Builds a stable structural record while preserving every field
 * produced by earlier pipeline stages.
 *
 * This stage also normalizes the stem-length measurements required
 * by downstream form analysis.
 *
 * Length fields:
 * - token_length
 * - matched_suffix_length
 * - token_length_after_suffix_removal
 * - marked_stem_length
 * - plain_stem_length
 * - expanded_skeleton_length
 * - structural_letter_count
 *
 * Important:
 * - token_length_after_suffix_removal counts the marked stem,
 *   including Arabic diacritics.
 * - plain_stem_length counts letters after diacritics are removed.
 * - structural_letter_count uses the extracted letters array where
 *   available.
 *
 * This function does not detect or change the verb form. It only
 * prepares reliable measurements for downstream form detection and
 * validation.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_build_structure(array $analysis): array
{
    /*
     * Preserve every field produced by earlier pipeline stages.
     */
    $result = $analysis;

    /*
     * Safely convert scalar values to strings.
     */
    $toString = static function ($value): string {
        if (is_string($value)) {
            return $value;
        }

        if (
            is_int($value)
            || is_float($value)
            || is_bool($value)
        ) {
            return (string) $value;
        }

        return '';
    };

    /*
     * Safely preserve an array.
     */
    $toArray = static function ($value): array {
        return is_array($value)
            ? $value
            : array();
    };

    /*
     * Safely convert a value to a non-negative integer.
     *
     * Returns null when the value is not numeric so that a fallback
     * can be calculated.
     */
    $toNonNegativeInt = static function ($value): ?int {
        if (is_int($value)) {
            return $value >= 0
                ? $value
                : null;
        }

        if (
            is_string($value)
            && $value !== ''
            && ctype_digit($value)
        ) {
            return (int) $value;
        }

        if (
            is_float($value)
            && $value >= 0
            && floor($value) === $value
        ) {
            return (int) $value;
        }

        return null;
    };

    /*
     * Count Unicode characters safely.
     *
     * Arabic diacritics are counted as separate characters. This
     * matches the current token-length and marked-stem-length output.
     */
    $unicodeLength = static function (string $value): int {
        if ($value === '') {
            return 0;
        }

        if (function_exists('mb_strlen')) {
            return mb_strlen($value, 'UTF-8');
        }

        /*
         * Fallback when mbstring is unavailable.
         */
        $matched = preg_match_all('/./us', $value, $characters);

        return $matched !== false
            ? $matched
            : 0;
    };

    /*
     * Original token information.
     */
    $result['token'] = $toString(
        $analysis['token'] ?? ''
    );

    $result['matched_suffix'] = $toString(
        $analysis['matched_suffix'] ?? ''
    );

    $result['marked_stem'] = $toString(
        $analysis['marked_stem'] ?? ''
    );

    $result['plain_stem'] = $toString(
        $analysis['plain_stem'] ?? ''
    );

    $result['expanded_skeleton'] = $toString(
        $analysis['expanded_skeleton'] ?? ''
    );

    /*
     * Preserve useful suffix metadata.
     */
    if (array_key_exists('matched_suffix_entry', $analysis)) {
        $result['matched_suffix_entry'] =
            $analysis['matched_suffix_entry'];
    }

    /*
     * Preserve arrays before calculating structural lengths.
     */
    $result['letters'] = $toArray(
        $analysis['letters'] ?? array()
    );

    $result['structural_clusters'] = $toArray(
        $analysis['structural_clusters'] ?? array()
    );

    if (
        isset($analysis['clusters'])
        && is_array($analysis['clusters'])
    ) {
        $result['clusters'] = $analysis['clusters'];
    } else {
        $result['clusters'] =
            $result['structural_clusters'];
    }

    /*
     * ========================================================
     * Length measurements
     * ========================================================
     */

    /*
     * Complete token length, including Arabic diacritics.
     */
    $tokenLength = $toNonNegativeInt(
        $analysis['token_length'] ?? null
    );

    if ($tokenLength === null) {
        $tokenLength = $unicodeLength(
            $result['token']
        );
    }

    $result['token_length'] = $tokenLength;

    /*
     * Matched suffix length, including Arabic diacritics.
     */
    $matchedSuffixLength = $toNonNegativeInt(
        $analysis['matched_suffix_length'] ?? null
    );

    if ($matchedSuffixLength === null) {
        $matchedSuffixLength = $unicodeLength(
            $result['matched_suffix']
        );
    }

    $result['matched_suffix_length'] =
        $matchedSuffixLength;

    /*
     * Raw stem length after suffix removal.
     *
     * Prefer the value produced by the suffix-removal stage because
     * that stage knows the exact retained marked stem.
     */
    $stemLengthAfterSuffix = $toNonNegativeInt(
        $analysis['token_length_after_suffix_removal']
            ?? null
    );

    /*
     * Support possible alternative names used by test pages.
     */
    if ($stemLengthAfterSuffix === null) {
        $stemLengthAfterSuffix = $toNonNegativeInt(
            $analysis['length_after_suffix_removal']
                ?? null
        );
    }

    if ($stemLengthAfterSuffix === null) {
        $stemLengthAfterSuffix = $unicodeLength(
            $result['marked_stem']
        );
    }

    $result['token_length_after_suffix_removal'] =
        $stemLengthAfterSuffix;

    /*
     * Explicit alias describing what this value measures.
     *
     * This avoids ambiguity in downstream functions and reports.
     */
    $result['marked_stem_length'] =
        $stemLengthAfterSuffix;

    /*
     * Plain stem length after Arabic marks have been removed.
     */
    $plainStemLength = $toNonNegativeInt(
        $analysis['plain_stem_length'] ?? null
    );

    if ($plainStemLength === null) {
        $plainStemLength = $unicodeLength(
            $result['plain_stem']
        );
    }

    $result['plain_stem_length'] =
        $plainStemLength;

    /*
     * Expanded skeleton length.
     *
     * This can differ from plain_stem_length when shadda or another
     * structural feature has been expanded into repeated letters.
     */
    $expandedSkeletonLength = $toNonNegativeInt(
        $analysis['expanded_skeleton_length'] ?? null
    );

    if ($expandedSkeletonLength === null) {
        $expandedSkeletonLength = $unicodeLength(
            $result['expanded_skeleton']
        );
    }

    $result['expanded_skeleton_length'] =
        $expandedSkeletonLength;

    /*
     * Number of extracted structural letters.
     *
     * Prefer the letters array because it represents the output of
     * the structural extraction stage.
     */
    if (count($result['letters']) > 0) {
        $structuralLetterCount =
            count($result['letters']);
    } else {
        $structuralLetterCount =
            $expandedSkeletonLength;
    }

    $result['structural_letter_count'] =
        $structuralLetterCount;

    /*
     * A normalized length profile can be passed intact to later
     * functions without requiring them to resolve field aliases.
     */
    $result['stem_length_profile'] = array(
        'token_length' =>
            $tokenLength,

        'matched_suffix_length' =>
            $matchedSuffixLength,

        'marked_stem_length' =>
            $stemLengthAfterSuffix,

        'plain_stem_length' =>
            $plainStemLength,

        'expanded_skeleton_length' =>
            $expandedSkeletonLength,

        'structural_letter_count' =>
            $structuralLetterCount,
    );

    /*
     * Length information is considered available when a stem exists
     * in any normalized representation.
     */
    $result['has_stem_length'] =
        $stemLengthAfterSuffix > 0
        || $plainStemLength > 0
        || $expandedSkeletonLength > 0;

    /*
     * Basic arithmetic consistency check.
     *
     * This does not invalidate the analysis. It simply exposes
     * disagreements between the suffix stage and the supplied
     * lengths for debugging.
     */
    $expectedMarkedStemLength =
        max(0, $tokenLength - $matchedSuffixLength);

    $result['expected_length_after_suffix_removal'] =
        $expectedMarkedStemLength;

    $result['stem_length_is_consistent'] =
        $stemLengthAfterSuffix ===
        $expectedMarkedStemLength;

    /*
     * Structural positions.
     */
    $result['vn'] = $toString(
        $analysis['vn'] ?? ''
    );

    $result['vm'] = $toString(
        $analysis['vm'] ?? ''
    );

    $result['fs'] = $toString(
        $analysis['fs'] ?? ''
    );

    /*
     * Root framework.
     */
    $result['r0'] = $toString(
        $analysis['r0'] ?? ''
    );

    $result['v1'] = $toString(
        $analysis['v1'] ?? ''
    );

    $result['r1'] = $toString(
        $analysis['r1'] ?? ''
    );

    $result['v2'] = $toString(
        $analysis['v2'] ?? ''
    );

    $result['r2'] = $toString(
        $analysis['r2'] ?? ''
    );

    /*
     * Repetition flags.
     */
    $result['r1_repeat'] = isset(
        $analysis['r1_repeat']
    )
        ? (bool) $analysis['r1_repeat']
        : false;

    $result['r2_repeat'] = isset(
        $analysis['r2_repeat']
    )
        ? (bool) $analysis['r2_repeat']
        : false;

    /*
     * Radical marks.
     */
    $result['r0_marks'] = $toString(
        $analysis['r0_marks'] ?? ''
    );

    $result['r1_marks'] = $toString(
        $analysis['r1_marks'] ?? ''
    );

    $result['r2_marks'] = $toString(
        $analysis['r2_marks'] ?? ''
    );

    /*
     * Form information.
     *
     * Preserve an earlier selected form. This function does not
     * independently infer a form from length.
     */
    $form = '';

    if (isset($analysis['form'])) {
        $form = $toString(
            $analysis['form']
        );
    }

    if (
        $form === ''
        && isset($analysis['detected_form'])
    ) {
        $form = $toString(
            $analysis['detected_form']
        );
    }

    $result['form'] = $form;
    $result['detected_form'] = $form;

    /*
     * Construct the root only when calculated radical values exist.
     */
    $rootLetters = array();

    foreach (
        array('r0', 'r1', 'r2')
        as $position
    ) {
        $letter = $toString(
            $result[$position] ?? ''
        );

        if ($letter !== '') {
            $rootLetters[] = $letter;
        }
    }

    /*
     * Do not erase an earlier valid root when the radical stage has
     * not produced any positions.
     */
    if (count($rootLetters) > 0) {
        $result['root_letters'] =
            $rootLetters;

        $result['root'] =
            implode('', $rootLetters);
    } else {
        $result['root_letters'] = $toArray(
            $analysis['root_letters'] ?? array()
        );

        $result['root'] = $toString(
            $analysis['root'] ?? ''
        );
    }

    return $result;
}
/**
 * ============================================================
 * FUNCTION 10
 * Extract Form Sign
 * ============================================================
 *
 * Preserves and normalizes an fs value assigned by an earlier
 * structural extraction stage.
 *
 * This function does not independently detect the form sign.
 */
function ace_extract_form_sign(array $analysis): array
{
    /*
     * Do not overwrite a valid form sign.
     */
    if (
        isset($analysis['fs'])
        && (
            is_string($analysis['fs'])
            || is_int($analysis['fs'])
            || is_float($analysis['fs'])
        )
    ) {
        $analysis['fs'] = (string) $analysis['fs'];
    } else {
        $analysis['fs'] = '';
    }

    return $analysis;
}
/**
 * Return the first short vowel attached to a cluster.
 *
 * Recognized vowels:
 * َ  fatha
 * ُ  damma
 * ِ  kasra
 */
function ace_extract_stem_slot_vowel(
    array $cluster
): string {
    $marksValue = $cluster['marks'] ?? [];

    if (is_array($marksValue)) {
        $marks = $marksValue;
    } elseif (is_string($marksValue)) {
        /*
         * Support a marks value such as:
         *
         * َ
         * َّ
         * ُْ
         */
        $marks = ace_chars($marksValue);
    } else {
        $marks = [];
    }

    foreach ($marks as $mark) {
        if (!is_string($mark)) {
            continue;
        }

        if (
            $mark === "\u{064E}" // َ fatha
            || $mark === "\u{064F}" // ُ damma
            || $mark === "\u{0650}" // ِ kasra
        ) {
            return $mark;
        }
    }

    return '';
}
function ace_extract_stem_slots(
    $input,
    array $analysis = array()
): array {
    /*
     * Supported calling styles:
     *
     * ace_extract_stem_slots($analysis)
     * ace_extract_stem_slots($markedStem, $analysis)
     */
    if (is_array($input)) {
        $result = $input;

        $markedStem = (
            isset($result['marked_stem'])
            && is_string($result['marked_stem'])
        )
            ? $result['marked_stem']
            : '';
    } else {
        $result = $analysis;

        $markedStem = is_string($input)
            ? $input
            : '';
    }

    /*
     * Reset only values owned by this stage.
     */
    $result = array_replace(
        $result,
        array(
            'vn' => '',
            'vm' => '',
            'fs' => '',

            /*
             * Noun/participle prefix.
             *
             * Example:
             * مُسْتَدْرِسُونَ
             *
             * np = م
             */
            'np' => '',

            'r0' => '',
            'v1' => '',
            'r1' => '',
            'v2' => '',
            'r2' => '',

            'r1_repeat' => false,
            'r2_repeat' => false,

            'r0_marks' => '',
            'r1_marks' => '',
            'r2_marks' => '',

            'initial_onset' => '',
            'structural_clusters' => array(),
            'root_cluster_indexes' => array(),
        )
    );

    $result['marked_stem'] = $markedStem;

    if ($markedStem === '') {
        return $result;
    }

    /*
     * Build letter-plus-mark clusters.
     */
    $rawClusters = ace_stem_position_make_clusters(
        $markedStem
    );

    if (!is_array($rawClusters)) {
        return $result;
    }

    /*
     * Normalize cluster structure.
     */
    $clusters = array();

    foreach ($rawClusters as $cluster) {
        if (
            !is_array($cluster)
            || !isset($cluster['letter'])
            || !is_string($cluster['letter'])
            || $cluster['letter'] === ''
        ) {
            continue;
        }

        $letter = $cluster['letter'];

        /*
         * Support:
         *
         * 'marks' => 'َ'
         *
         * and:
         *
         * 'marks' => array('َ', 'ّ')
         */
        $marksValue = isset($cluster['marks'])
            ? $cluster['marks']
            : '';

        if (is_array($marksValue)) {
            $marks = implode(
                '',
                array_map(
                    static function ($mark): string {
                        return is_string($mark)
                            ? $mark
                            : '';
                    },
                    $marksValue
                )
            );
        } elseif (is_string($marksValue)) {
            $marks = $marksValue;
        } else {
            $marks = '';
        }

        $text = (
            isset($cluster['text'])
            && is_string($cluster['text'])
        )
            ? $cluster['text']
            : $letter . $marks;

        $clusters[] = array(
            'letter' => $letter,
            'marks'  => $marks,
            'text'   => $text,
        );
    }

    $result['structural_clusters'] = $clusters;

    if ($clusters === array()) {
        return $result;
    }

    $letters = array_map(
        static function (array $cluster): string {
            return isset($cluster['letter'])
                ? (string) $cluster['letter']
                : '';
        },
        $clusters
    );

    $clusterCount = count($clusters);

    $result['initial_onset'] = isset($letters[0])
        ? $letters[0]
        : '';

    /*
     * Index at which the root-bearing portion begins.
     */
    $rootStart = 0;

    /*
     * Structural form flags used when selecting radicals.
     */
    $isFormX = false;
    $isFormVOrVI = false;
    $isFormVII = false;
    $isFormVIII = false;

    /*
     * ------------------------------------------------------------
     * Form X: imperfect
     *
     * ي + ست + root
     * ت + ست + root
     * أ + ست + root
     * ن + ست + root
     *
     * Examples:
     *
     * يَسْتَدْرِسُ
     * أَسْتَدْرِسُ
     *
     * The surface sequence is يست / تست / أست / نست, but the
     * form-sign value remains است for consistent form detection.
     * ------------------------------------------------------------
     */
    if (
        $clusterCount >= 6
        && in_array(
            isset($letters[0]) ? $letters[0] : '',
            array('ي', 'ت', 'أ', 'ن'),
            true
        )
        && isset($letters[1], $letters[2])
        && $letters[1] === 'س'
        && $letters[2] === 'ت'
    ) {
        $result['vn'] = $letters[0];
        $result['fs'] = 'است';

        $rootStart = 3;
        $isFormX = true;
    }

    /*
     * ------------------------------------------------------------
     * Form X: active/passive participle
     *
     * م + ست + root
     *
     * Example:
     *
     * مُسْتَدْرِسُونَ
     *
     * np records the participial م.
     * ------------------------------------------------------------
     */
    elseif (
        $clusterCount >= 6
        && isset($letters[0], $letters[1], $letters[2])
        && $letters[0] === 'م'
        && $letters[1] === 'س'
        && $letters[2] === 'ت'
    ) {
        $result['np'] = 'م';
        $result['fs'] = 'است';

        $rootStart = 3;
        $isFormX = true;
    }

    /*
     * ------------------------------------------------------------
     * Form X: perfect, imperative, or verbal noun
     *
     * ا + س + ت + root
     *
     * Examples:
     *
     * اِسْتَدْرَسْتُمْ
     * اِسْتِدْرَاسٌ
     *
     * Hamzated spellings are accepted defensively.
     * ------------------------------------------------------------
     */
    elseif (
        $clusterCount >= 6
        && isset($letters[0], $letters[1], $letters[2])
        && in_array(
            $letters[0],
            array('ا', 'إ', 'أ'),
            true
        )
        && $letters[1] === 'س'
        && $letters[2] === 'ت'
    ) {
        $result['fs'] = 'است';

        $rootStart = 3;
        $isFormX = true;
    }

    /*
     * ------------------------------------------------------------
     * Forms V and VI imperfect onset
     *
     * imperfect prefix + derivational ت + root
     *
     * Examples:
     *
     * يَتَدَرَّسُ
     * يُتَدَرَّسُ
     * يَتَدَارَسُ
     *
     * vn = ي
     * vm = ت
     * ------------------------------------------------------------
     */
    elseif (
        $clusterCount >= 5
        && in_array(
            isset($letters[0]) ? $letters[0] : '',
            array('ي', 'ت', 'أ', 'ن'),
            true
        )
        && isset($letters[1])
        && $letters[1] === 'ت'
    ) {
        $result['vn'] = $letters[0];
        $result['vm'] = 'ت';

        $rootStart = 2;
        $isFormVOrVI = true;
    }

    /*
     * ------------------------------------------------------------
     * Ordinary imperfect onset
     *
     * ي / ت / أ / ن + root
     *
     * A minimum of three clusters must remain.
     * ------------------------------------------------------------
     */
    elseif (
        $clusterCount >= 4
        && in_array(
            isset($letters[0]) ? $letters[0] : '',
            array('ي', 'ت', 'أ', 'ن'),
            true
        )
    ) {
        $result['vn'] = $letters[0];
        $rootStart = 1;
    }

    /*
     * ------------------------------------------------------------
     * Initial alif of an imperative or derived form.
     * ------------------------------------------------------------
     */
    elseif (
        $clusterCount >= 4
        && in_array(
            isset($letters[0]) ? $letters[0] : '',
            array('ا', 'إ', 'أ'),
            true
        )
    ) {
        $result['vn'] = $letters[0];
        $rootStart = 1;
    }

    $remainingCount = $clusterCount - $rootStart;

    /*
     * ------------------------------------------------------------
     * Form VII
     *
     * ن + root
     *
     * Examples:
     *
     * اِنْدَرَسَ
     * يَنْدَرِسُ
     *
     * For an imperfect, the initial ي/ت/أ/ن has already been
     * recorded as vn.
     * ------------------------------------------------------------
     */
    if (
        !$isFormX
        && $remainingCount >= 4
        && isset($letters[$rootStart])
        && $letters[$rootStart] === 'ن'
    ) {
        $result['fs'] = 'ن';

        $rootStart++;
        $remainingCount--;

        $isFormVII = true;
    }

    /*
     * ------------------------------------------------------------
     * Determine root-cluster indexes.
     * ------------------------------------------------------------
     */
    $rootIndexes = array();

    /*
     * Form VIII:
     *
     * r0 + ت + r1 + r2
     *
     * Examples:
     *
     * اِكْتَتَبَ
     * يَكْتَتِبُ
     *
     * Do not apply this rule to Form X, because its ت belongs
     * to the است signature and has already been handled.
     * ------------------------------------------------------------
     */
    if (
        !$isFormX
        && !$isFormVOrVI
        && !$isFormVII
        && $remainingCount >= 4
        && isset($letters[$rootStart + 1])
        && $letters[$rootStart + 1] === 'ت'
    ) {
        $result['vm'] = 'ت';

        $rootIndexes = array(
            $rootStart,
            $rootStart + 2,
            $rootStart + 3,
        );

        $isFormVIII = true;
    }

    /*
     * ------------------------------------------------------------
     * Long alif after r0
     *
     * Form III or Form VI structure:
     *
     * r0 + ا + r1 + r2
     *
     * Example:
     *
     * يَتَدَارَسُ
     *
     * d + ā + r + s
     *
     * Root indexes:
     *
     * d, r, s
     * ------------------------------------------------------------
     */
    elseif (
        $remainingCount >= 4
        && isset(
            $letters[$rootStart],
            $letters[$rootStart + 1],
            $letters[$rootStart + 2],
            $letters[$rootStart + 3]
        )
        && in_array(
            $letters[$rootStart + 1],
            array('ا', 'ى'),
            true
        )
    ) {
        $rootIndexes = array(
            $rootStart,
            $rootStart + 2,
            $rootStart + 3,
        );
    }

    /*
     * ------------------------------------------------------------
     * Long alif after r1
     *
     * Common verbal-noun structure:
     *
     * r0 + r1 + ا + r2
     *
     * Example:
     *
     * اِسْتِدْرَاسٌ
     *
     * d + r + ā + s
     *
     * Root indexes:
     *
     * d, r, s
     * ------------------------------------------------------------
     */
    elseif (
        $remainingCount >= 4
        && isset(
            $letters[$rootStart],
            $letters[$rootStart + 1],
            $letters[$rootStart + 2],
            $letters[$rootStart + 3]
        )
        && in_array(
            $letters[$rootStart + 2],
            array('ا', 'ى'),
            true
        )
    ) {
        $rootIndexes = array(
            $rootStart,
            $rootStart + 1,
            $rootStart + 3,
        );
    }

    /*
     * ------------------------------------------------------------
     * Ordinary radical sequence.
     *
     * Select only the first three root-bearing clusters.
     *
     * This intentionally ignores unresolved suffix clusters after
     * the third radical. Therefore:
     *
     * يَسْتَدْرِسُونَ
     *
     * becomes:
     *
     * ي + ست + د ر س + ون
     *
     * and only د ر س are assigned as radicals.
     *
     * Similarly:
     *
     * اِسْتَدْرَسْتُمْ
     *
     * becomes:
     *
     * است + د ر س + تم
     *
     * and only د ر س are assigned.
     * ------------------------------------------------------------
     */
    else {
        for (
            $index = $rootStart;
            $index < $clusterCount
                && count($rootIndexes) < 3;
            $index++
        ) {
            $rootIndexes[] = $index;
        }
    }

    $result['root_cluster_indexes'] = $rootIndexes;

    /*
     * Safely retrieve root clusters.
     */
    $r0Cluster = (
        isset($rootIndexes[0])
        && isset($clusters[$rootIndexes[0]])
    )
        ? $clusters[$rootIndexes[0]]
        : null;

    $r1Cluster = (
        isset($rootIndexes[1])
        && isset($clusters[$rootIndexes[1]])
    )
        ? $clusters[$rootIndexes[1]]
        : null;

    $r2Cluster = (
        isset($rootIndexes[2])
        && isset($clusters[$rootIndexes[2]])
    )
        ? $clusters[$rootIndexes[2]]
        : null;

    /*
     * Assign r0 and v1.
     */
    if (is_array($r0Cluster)) {
        $result['r0'] = isset($r0Cluster['letter'])
            ? (string) $r0Cluster['letter']
            : '';

        $result['r0_marks'] = isset($r0Cluster['marks'])
            ? (string) $r0Cluster['marks']
            : '';

        $result['v1'] = ace_extract_stem_slot_vowel(
            $r0Cluster
        );
    }

    /*
     * Assign r1 and v2.
     */
    if (is_array($r1Cluster)) {
        $result['r1'] = isset($r1Cluster['letter'])
            ? (string) $r1Cluster['letter']
            : '';

        $result['r1_marks'] = isset($r1Cluster['marks'])
            ? (string) $r1Cluster['marks']
            : '';

        $result['v2'] = ace_extract_stem_slot_vowel(
            $r1Cluster
        );

        /*
         * strpos() is used instead of str_contains() for
         * compatibility with PHP versions before PHP 8.
         */
        $result['r1_repeat'] = (
            strpos($result['r1_marks'], 'ّ') !== false
        );
    }

    /*
     * Assign r2.
     */
    if (is_array($r2Cluster)) {
        $result['r2'] = isset($r2Cluster['letter'])
            ? (string) $r2Cluster['letter']
            : '';

        $result['r2_marks'] = isset($r2Cluster['marks'])
            ? (string) $r2Cluster['marks']
            : '';

        $result['r2_repeat'] = (
            strpos($result['r2_marks'], 'ّ') !== false
        );
    }

    return $result;
}
function ace_extract_comparative_root(array $clusters): array
{
    // Expected clusters:
    // 0 = أَ
    // 1 = r0ْ
    // 2 = r1َ
    // 3 = r2
    if (
        count($clusters) === 4 &&
        ($clusters[0]['letter'] ?? '') === 'أ'
    ) {
        return [
            'r0'   => $clusters[1]['letter'] ?? '',
            'r1'   => $clusters[2]['letter'] ?? '',
            'r2'   => $clusters[3]['letter'] ?? '',
            'root' => implode('', [
                $clusters[1]['letter'] ?? '',
                $clusters[2]['letter'] ?? '',
                $clusters[3]['letter'] ?? '',
            ]),
        ];
    }

    return [];
}
function ace_normalize_radical(string $letter): string
{
    $hamzaForms = [
        'أ',
        'إ',
        'ؤ',
        'ئ',
        'ء',
        'آ',
    ];

    if (in_array($letter, $hamzaForms, true)) {
        return 'ء';
    }

    return $letter;
}

/**
 * Safely converts analyzer values to displayable text.
 * Arrays are recursively flattened without producing
 * "Array to string conversion" warnings.
 */
function ace_value_to_string(mixed $value, string $separator = ''): string
{
    if ($value === null) {
        return '';
    }

    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }

    if (is_scalar($value)) {
        return (string) $value;
    }

    if (is_array($value)) {
        $parts = [];

        foreach ($value as $item) {
            $text = ace_value_to_string($item, $separator);

            if ($text !== '') {
                $parts[] = $text;
            }
        }

        return implode($separator, $parts);
    }

    return '';
}
/**
 * ============================================================
 * FUNCTION 1
 * Extract stem positions
 * ============================================================
 *
 * Accepts either:
 * - a complete analysis array containing marked_stem; or
 * - a marked-stem string.
 *
 * Two length systems are maintained:
 *
 * 1. Marked-unit length
 *
 *    Every Arabic base letter and every attached mark occupies
 *    one unit.
 *
 *        دَرَّس      = 6 units
 *        تَدَرَّس    = 8 units
 *        يَتَدَرَّس  = 10 units
 *
 * 2. Structural-position length
 *
 *    Every base-letter cluster occupies one position and every
 *    shadda contributes one additional consonantal position.
 *
 *        دَرَّس      = 3 clusters + 1 shadda = 4
 *        تَدَرَّس    = 4 clusters + 1 shadda = 5
 *        يَتَدَرَّس  = 5 clusters + 1 shadda = 6
 *
 * Marked-unit length is used for compatibility with the
 * dedicated effective-length stage. Structural-position length
 * is used when deciding whether a doubled triliteral structure
 * is actually supported.
 *
 * @param string|array<string, mixed> $input
 *
 * @return array<string, mixed>
 */
function ace_extract_stem_position($input): array
{
    /*
     * Preserve all upstream pipeline information.
     */
    $result = is_array($input)
        ? $input
        : array();

    /*
     * Resolve marked_stem.
     */
    if (is_string($input)) {
        $markedStem = $input;
    } elseif (
        isset($result['marked_stem'])
        && is_string($result['marked_stem'])
    ) {
        $markedStem = $result['marked_stem'];
    } else {
        $markedStem = '';
    }

    /*
     * Preserve length fields before stage-owned defaults are
     * applied.
     */
    $upstreamSurfaceLength =
        isset($result['stem_surface_length'])
        && is_numeric($result['stem_surface_length'])
            ? (int) $result['stem_surface_length']
            : null;

    $upstreamShaddaCount =
        isset($result['stem_shadda_count'])
        && is_numeric($result['stem_shadda_count'])
            ? (int) $result['stem_shadda_count']
            : null;

    $upstreamEffectiveLength =
        isset($result['stem_effective_length'])
        && is_numeric($result['stem_effective_length'])
            ? (int) $result['stem_effective_length']
            : null;

    /*
     * Reset only fields owned by this stage.
     */
    $result = array_replace(
        $result,
        ace_stem_position_defaults()
    );

    $result['marked_stem'] = $markedStem;

    /*
     * Diagnostics and structural metadata.
     */
    $result['medial_shadda_detected'] = false;
    $result['medial_shadda_index'] = null;
    $result['medial_shadda_length_supported'] = false;

    $result['stem_position_length_source'] = '';
    $result['stem_position_effective_length'] = 0;
    $result['stem_structural_position_length'] = 0;

    $result['radical_indexes'] = array();
    $result['skip_indexes'] = array();

    $result['form_x_prefix_detected'] = false;
    $result['imperfect_prefix_detected'] = false;
    $result['derivational_t_detected'] = false;

    if ($markedStem === '') {
        return $result;
    }

    /*
     * Build one cluster per base letter.
     */
    $clusters = ace_stem_position_make_clusters(
        $markedStem
    );

    if (!is_array($clusters)) {
        $clusters = array();
    }

    $result['clusters'] = $clusters;
    $result['structural_clusters'] = $clusters;

    if ($clusters === array()) {
        return $result;
    }

    $clusterCount = count($clusters);

    /*
     * Read a sequence of cluster letters without marks.
     */
    $readLetters = static function (
        array $sourceClusters,
        int $start,
        int $length
    ): string {
        if ($start < 0 || $length <= 0) {
            return '';
        }

        $letters = '';

        $end = min(
            count($sourceClusters),
            $start + $length
        );

        for ($i = $start; $i < $end; $i++) {
            $letters .=
                ace_stem_position_cluster_letter(
                    $sourceClusters,
                    $i
                );
        }

        return $letters;
    };

    /*
     * --------------------------------------------------------
     * Calculate both length systems
     * --------------------------------------------------------
     */
    $localShaddaCount = 0;

    foreach ($clusters as $clusterIndex => $cluster) {
        $marks =
            ace_stem_position_cluster_marks(
                $clusters,
                (int) $clusterIndex
            );

        if (ace_stem_position_has_shadda($marks)) {
            $localShaddaCount++;
        }
    }

    /*
     * Number of written base-letter clusters.
     */
    $localSurfaceLength = $clusterCount;

    /*
     * Consonantal structural positions. Shadda contributes one
     * additional position.
     */
    $localStructuralPositionLength =
        $clusterCount + $localShaddaCount;

    /*
     * Marked-unit length. This corresponds to values such as:
     *
     *     دَرَّس      = 6
     *     تَدَرَّس    = 8
     *     يَتَدَرَّس  = 10
     */
    $localMarkedUnitLength =
        function_exists('mb_strlen')
            ? mb_strlen($markedStem, 'UTF-8')
            : strlen($markedStem);

    /*
     * Prefer the dedicated upstream effective-length result.
     */
    if (
        $upstreamEffectiveLength !== null
        && $upstreamEffectiveLength > 0
    ) {
        $markedEffectiveLength =
            $upstreamEffectiveLength;

        $result['stem_position_length_source'] =
            'stem_effective_length';
    } else {
        $markedEffectiveLength =
            $localMarkedUnitLength;

        $result['stem_position_length_source'] =
            'marked_stem_fallback';
    }

    $result['stem_surface_length'] =
        $upstreamSurfaceLength !== null
        && $upstreamSurfaceLength >= 0
            ? $upstreamSurfaceLength
            : $localSurfaceLength;

    $result['stem_shadda_count'] =
        $upstreamShaddaCount !== null
        && $upstreamShaddaCount >= 0
            ? $upstreamShaddaCount
            : $localShaddaCount;

    $result['stem_effective_length'] =
        $markedEffectiveLength;

    $result['stem_position_effective_length'] =
        $markedEffectiveLength;

    $result['stem_structural_position_length'] =
        $localStructuralPositionLength;

    /*
     * --------------------------------------------------------
     * Determine grammatical context
     * --------------------------------------------------------
     */
    $lengthContext =
        isset($result['stem_length_context'])
        && is_string($result['stem_length_context'])
            ? strtolower(trim(
                $result['stem_length_context']
            ))
            : '';

    $matchedSuffixEntry =
        isset($result['matched_suffix_entry'])
        && is_string($result['matched_suffix_entry'])
            ? strtolower(
                $result['matched_suffix_entry']
            )
            : '';

    $matchedFamily =
        isset($result['matched_family'])
        && is_string($result['matched_family'])
            ? strtolower(trim(
                $result['matched_family']
            ))
            : '';

    $looksImperfect =
        strpos($lengthContext, 'imperfect') !== false
        || strpos(
            $matchedSuffixEntry,
            'families=imperfect'
        ) !== false
        || $matchedFamily === 'imperfect'
        || $matchedFamily === 'subjunctive'
        || $matchedFamily === 'jussive';

    /*
     * Read frequently used initial sequences.
     */
    $firstLetter = ace_stem_position_cluster_letter(
        $clusters,
        0
    );

    $firstTwoLetters = $readLetters(
        $clusters,
        0,
        2
    );

    $firstThreeLetters = $readLetters(
        $clusters,
        0,
        3
    );

    $secondAndThirdLetters = $readLetters(
        $clusters,
        1,
        2
    );

    /*
     * --------------------------------------------------------
     * Consume high-confidence prefixes
     * --------------------------------------------------------
     */
    $workingIndex = 0;
    $consumedIndexes = array();

    /*
     * Perfect, imperative, and verbal-noun Form X:
     *
     *     است + r0 + r1 + r2
     */
    $perfectFormX =
        $clusterCount >= 6
        && $firstThreeLetters === 'است';

    /*
     * Imperfect Form X:
     *
     *     ي + ست + r0 + r1 + r2
     *     ت + ست + r0 + r1 + r2
     *     أ + ست + r0 + r1 + r2
     *     ن + ست + r0 + r1 + r2
     *
     * The initial person marker replaces the initial alif of
     * the perfect/imperative است sign.
     */
    $imperfectFormX =
        $looksImperfect
        && $clusterCount >= 6
        && in_array(
            $firstLetter,
            array('ي', 'ت', 'أ', 'ن'),
            true
        )
        && $secondAndThirdLetters === 'ست';

    if ($perfectFormX) {
        /*
         * Preserve the marked surface prefix.
         */
        $result['fs'] =
            ace_stem_position_join_clusters(
                $clusters,
                0,
                3
            );

        $result['form_x_prefix_detected'] = true;

        $workingIndex = 3;
        $consumedIndexes = array(0, 1, 2);
    } elseif ($imperfectFormX) {
        $result['vn'] =
            ace_stem_position_cluster_text(
                $clusters,
                0
            );

        /*
         * Publish the canonical Form X sign. Downstream form
         * detection can therefore use one value for perfect and
         * imperfect Form X.
         */
        $result['fs'] = 'است';

        $result['form_x_prefix_detected'] = true;
        $result['imperfect_prefix_detected'] = true;

        $workingIndex = 3;
        $consumedIndexes = array(0, 1, 2);
    } else {
        /*
         * Ordinary imperfect person marker.
         *
         * Require at least three surface clusters after it.
         */
        $canReserveImperfectMarker =
            $looksImperfect
            && $clusterCount >= 4
            && in_array(
                $firstLetter,
                array('ي', 'ت', 'أ', 'ن'),
                true
            );

        if ($canReserveImperfectMarker) {
            $result['vn'] =
                ace_stem_position_cluster_text(
                    $clusters,
                    0
                );

            $result['imperfect_prefix_detected'] = true;

            $workingIndex = 1;
            $consumedIndexes[] = 0;
        }
    }

    /*
     * --------------------------------------------------------
     * Locate medial shadda
     * --------------------------------------------------------
     *
     * A medial shadda represents doubled r1 only when:
     *
     * - there is a preceding radical;
     * - there is a following radical;
     * - enough structural positions remain;
     * - the shadda is not in a consumed prefix.
     */
    $medialShaddaIndexes = array();

    for (
        $i = max(1, $workingIndex + 1);
        $i < $clusterCount - 1;
        $i++
    ) {
        $marks =
            ace_stem_position_cluster_marks(
                $clusters,
                $i
            );

        if (!ace_stem_position_has_shadda($marks)) {
            continue;
        }

        $r0CandidateIndex = $i - 1;
        $r1CandidateIndex = $i;
        $r2CandidateIndex = $i + 1;

        if (
            in_array(
                $r0CandidateIndex,
                $consumedIndexes,
                true
            )
            || in_array(
                $r1CandidateIndex,
                $consumedIndexes,
                true
            )
            || in_array(
                $r2CandidateIndex,
                $consumedIndexes,
                true
            )
        ) {
            continue;
        }

        $r0Candidate =
            ace_stem_position_cluster_letter(
                $clusters,
                $r0CandidateIndex
            );

        $r1Candidate =
            ace_stem_position_cluster_letter(
                $clusters,
                $r1CandidateIndex
            );

        $r2Candidate =
            ace_stem_position_cluster_letter(
                $clusters,
                $r2CandidateIndex
            );

        if (
            $r0Candidate === ''
            || $r1Candidate === ''
            || $r2Candidate === ''
        ) {
            continue;
        }

        /*
         * A doubled triliteral requires four structural
         * consonantal positions:
         *
         *     r0 + r1 + repeated-r1 + r2
         */
        $prefixPositionCount =
            max(
                0,
                $r0CandidateIndex - $workingIndex
            );

        $requiredStructuralLength =
            $workingIndex
            + $prefixPositionCount
            + 4;

        if (
            $localStructuralPositionLength
            < $requiredStructuralLength
        ) {
            continue;
        }

        /*
         * Once the ordinary imperfect marker has been consumed,
         * material before r0 is a derivational sign.
         *
         * For يَتَدَرَّس:
         *
         *     index 0 = imperfect ي
         *     index 1 = derivational ت
         *     index 2 = r0 د
         *     index 3 = r1 رّ
         *     index 4 = r2 س
         */
        if (
            $r0CandidateIndex > $workingIndex
            && $result['fs'] === ''
        ) {
            $result['fs'] =
                ace_stem_position_join_clusters(
                    $clusters,
                    $workingIndex,
                    $r0CandidateIndex - $workingIndex
                );

            for (
                $prefixIndex = $workingIndex;
                $prefixIndex < $r0CandidateIndex;
                $prefixIndex++
            ) {
                $consumedIndexes[] = $prefixIndex;
            }

            if (
                $readLetters(
                    $clusters,
                    $workingIndex,
                    $r0CandidateIndex - $workingIndex
                ) === 'ت'
            ) {
                $result['derivational_t_detected'] =
                    true;
            }
        }

        /*
         * Perfect Form V:
         *
         *     ت + r0 + r1ّ + r2
         *
         * When no imperfect marker was consumed, the initial ت
         * is the derivational onset and must not become r0.
         */
        if (
            !$looksImperfect
            && $workingIndex === 0
            && $r0CandidateIndex === 1
            && $firstLetter === 'ت'
        ) {
            $result['vn'] =
                ace_stem_position_cluster_text(
                    $clusters,
                    0
                );

            $result['derivational_t_detected'] =
                true;

            $consumedIndexes[] = 0;
        }

        $medialShaddaIndexes = array(
            $r0CandidateIndex,
            $r1CandidateIndex,
            $r2CandidateIndex
        );

        $result['medial_shadda_detected'] = true;
        $result['medial_shadda_index'] =
            $r1CandidateIndex;

        $result['medial_shadda_length_supported'] =
            true;

        break;
    }

    /*
     * --------------------------------------------------------
     * Select radical indexes
     * --------------------------------------------------------
     */
    if ($medialShaddaIndexes !== array()) {
        $radicalIndexes = $medialShaddaIndexes;
    } else {
        $radicalIndexes =
            ace_stem_position_select_radical_indexes(
                $clusters,
                $workingIndex
            );
    }

    if (!is_array($radicalIndexes)) {
        $radicalIndexes = array();
    }

    /*
     * Remove consumed prefix indexes from the general selector.
     */
    $filteredIndexes = array();

    foreach ($radicalIndexes as $index) {
        if (
            !is_int($index)
            && !ctype_digit((string) $index)
        ) {
            continue;
        }

        $index = (int) $index;

        if (
            $index < 0
            || $index >= $clusterCount
        ) {
            continue;
        }

        if (
            in_array(
                $index,
                $consumedIndexes,
                true
            )
        ) {
            continue;
        }

        if (
            in_array(
                $index,
                $filteredIndexes,
                true
            )
        ) {
            continue;
        }

        $filteredIndexes[] = $index;
    }

    sort($filteredIndexes);

    /*
     * If the selector returned more than three positions,
     * retain the final three unconsumed positions.
     */
    if (count($filteredIndexes) > 3) {
        $filteredIndexes = array_slice(
            $filteredIndexes,
            -3
        );
    }

    /*
     * Conservative fallback.
     *
     * Never fabricate a triliteral root from only two surface
     * clusters.
     */
    if (count($filteredIndexes) < 3) {
        $fallbackIndexes = array();

        for (
            $i = $workingIndex;
            $i < $clusterCount;
            $i++
        ) {
            if (
                in_array(
                    $i,
                    $consumedIndexes,
                    true
                )
            ) {
                continue;
            }

            $fallbackIndexes[] = $i;
        }

        if (count($fallbackIndexes) >= 3) {
            $filteredIndexes = array_slice(
                $fallbackIndexes,
                -3
            );
        }
    }

    $radicalIndexes = $filteredIndexes;

    $result['radical_indexes'] =
        $radicalIndexes;

    /*
     * Publish all indexes that downstream stages must not
     * reinterpret as radicals.
     */
    $consumedIndexes = array_values(
        array_unique(
            array_map(
                'intval',
                $consumedIndexes
            )
        )
    );

    sort($consumedIndexes);

    $result['skip_indexes'] =
        $consumedIndexes;

    if ($radicalIndexes === array()) {
        return $result;
    }

    /*
     * Material between the working start and r0 is a
     * provisional form sign.
     */
    $firstRadicalIndex =
        isset($radicalIndexes[0])
            ? (int) $radicalIndexes[0]
            : null;

    if (
        $result['fs'] === ''
        && $firstRadicalIndex !== null
        && $firstRadicalIndex > $workingIndex
    ) {
        $result['fs'] =
            ace_stem_position_join_clusters(
                $clusters,
                $workingIndex,
                $firstRadicalIndex - $workingIndex
            );

        for (
            $i = $workingIndex;
            $i < $firstRadicalIndex;
            $i++
        ) {
            if (
                !in_array(
                    $i,
                    $result['skip_indexes'],
                    true
                )
            ) {
                $result['skip_indexes'][] = $i;
            }
        }

        sort($result['skip_indexes']);
    }

    /*
     * Assign a radical position.
     */
    $assignRadical = static function (
        array &$target,
        array $sourceClusters,
        int $radicalNumber,
        int $index
    ): void {
        $letter =
            ace_stem_position_cluster_letter(
                $sourceClusters,
                $index
            );

        $marks =
            ace_stem_position_cluster_marks(
                $sourceClusters,
                $index
            );

        $radicalKey = 'r' . $radicalNumber;
        $indexKey = $radicalKey . '_index';
        $marksKey = $radicalKey . '_marks';

        $target[$radicalKey] =
            ace_stem_position_normalize_radical(
                $letter
            );

        $target[$indexKey] = $index;
        $target[$marksKey] = $marks;
    };

    if (isset($radicalIndexes[0])) {
        $assignRadical(
            $result,
            $clusters,
            0,
            (int) $radicalIndexes[0]
        );

        $result['v1'] =
            $result['r0_marks'];
    }

    if (isset($radicalIndexes[1])) {
        $assignRadical(
            $result,
            $clusters,
            1,
            (int) $radicalIndexes[1]
        );

        $result['v2'] =
            $result['r1_marks'];

        /*
         * Calculate from the final selected radical rather than
         * relying only on the earlier candidate scan.
         */
        $result['r1_repeat'] =
            ace_stem_position_has_shadda(
                (string) $result['r1_marks']
            );
    }

    if (isset($radicalIndexes[2])) {
        $assignRadical(
            $result,
            $clusters,
            2,
            (int) $radicalIndexes[2]
        );

        $result['r2_repeat'] =
            ace_stem_position_has_shadda(
                (string) $result['r2_marks']
            );
    }

    /*
     * Build the lexical root.
     *
     * Shadda represents repetition structurally but does not
     * duplicate the radical in the lexical root.
     */
    $result['root'] =
        (string) $result['r0']
        . (string) $result['r1']
        . (string) $result['r2'];

    return $result;
}
/**
 * Determine whether a Unicode character is an Arabic
 * combining mark.
 *
 * @param mixed $character
 *
 * @return bool
 */
function ace_stem_position_is_mark($character): bool
{
    if (
        !is_string($character)
        || $character === ''
    ) {
        return false;
    }

    return preg_match(
        '/^[\x{0610}-\x{061A}'
        . '\x{064B}-\x{065F}'
        . '\x{0670}'
        . '\x{06D6}-\x{06ED}]$/u',
        $character
    ) === 1;
}


    function ace_stem_position_has_shadda($marks): bool
    {
        return is_string($marks)
            && $marks !== ''
            && mb_strpos($marks, "\u{0651}", 0, 'UTF-8') !== false;
    }


    function ace_stem_position_normalize_radical($letter): string
    {
        if (!is_string($letter)) {
            return '';
        }

        return in_array($letter, ['أ', 'إ', 'ؤ', 'ئ', 'ء', 'آ'], true)
            ? 'ء'
            : $letter;
    }

    function ace_stem_position_make_clusters($markedStem): array
    {
        if (!is_string($markedStem) || $markedStem === '') {
            return [];
        }

        $characters = preg_split('//u', $markedStem, -1, PREG_SPLIT_NO_EMPTY);

        if (!is_array($characters)) {
            return [];
        }

        $clusters = [];

        foreach ($characters as $character) {
            if (!is_string($character) || $character === '') {
                continue;
            }

            // Ignore tatweel; it is decorative and has no structural position.
            if ($character === "\u{0640}") {
                continue;
            }

            if (ace_stem_position_is_mark($character)) {
                $lastIndex = count($clusters) - 1;

                if ($lastIndex >= 0) {
                    $clusters[$lastIndex]['marks'] .= $character;
                    $clusters[$lastIndex]['text'] .= $character;
                }

                continue;
            }

            $clusters[] = [
                'letter' => $character,
                'marks'  => '',
                'text'   => $character,
            ];
        }

        return $clusters;
    }

    function ace_stem_position_cluster_value(
        array $clusters,
        int $index,
        string $key
    ): string {
        if ($index < 0 || !isset($clusters[$index]) || !is_array($clusters[$index])) {
            return '';
        }

        $value = $clusters[$index][$key] ?? '';

        return is_string($value) ? $value : '';
    }


    function ace_stem_position_cluster_letter(array $clusters, int $index): string
    {
        return ace_stem_position_cluster_value($clusters, $index, 'letter');
    }


    function ace_stem_position_cluster_marks(array $clusters, int $index): string
    {
        return ace_stem_position_cluster_value($clusters, $index, 'marks');
    }

    function ace_stem_position_cluster_text(array $clusters, int $index): string
    {
        return ace_stem_position_cluster_value($clusters, $index, 'text');
    }


    function ace_stem_position_join_clusters(
        array $clusters,
        int $start,
        int $length
    ): string {
        if ($start < 0 || $length <= 0) {
            return '';
        }

        $parts = [];
        $end = min(count($clusters), $start + $length);

        for ($index = $start; $index < $end; $index++) {
            $parts[] = ace_stem_position_cluster_text($clusters, $index);
        }

        return implode('', $parts);
    }
    function ace_stem_position_is_unmarked_long_vowel(
        array $clusters,
        int $index
    ): bool {
        return ace_stem_position_cluster_marks($clusters, $index) === ''
            && in_array(
                ace_stem_position_cluster_letter($clusters, $index),
                ['ا', 'و', 'ي', 'ى'],
                true
            );
    }
    function ace_stem_position_select_radical_indexes(
        array $clusters,
        int $startIndex,
        array $excludedIndexes = []
    ): array {
        $availableIndexes = [];
        $excludedLookup = array_fill_keys($excludedIndexes, true);

        for ($index = max(0, $startIndex); $index < count($clusters); $index++) {
            if (isset($excludedLookup[$index])) {
                continue;
            }

            if (ace_stem_position_cluster_letter($clusters, $index) !== '') {
                $availableIndexes[] = $index;
            }
        }

        if (count($availableIndexes) <= 3) {
            return $availableIndexes;
        }

        // Prefer consonantal positions when extra derivational long vowels exist.
        // Weak-root reconstruction remains the responsibility of later stages.
        $consonantIndexes = array_values(array_filter(
            $availableIndexes,
            static function (int $index) use ($clusters): bool {
                return !ace_stem_position_is_unmarked_long_vowel($clusters, $index);
            }
        ));

        return count($consonantIndexes) >= 3
            ? array_slice($consonantIndexes, -3)
            : array_slice($availableIndexes, -3);
    }
    function ace_stem_position_defaults(): array
    {
        return [
            'vn'                => '',
            'vm'                => '',
            'fs'                => '',
            'r0'                => '',
            'v1'                => '',
            'r1'                => '',
            'v2'                => '',
            'r2'                => '',
            'r1_repeat'         => false,
            'r2_repeat'         => false,
            'r0_marks'          => '',
            'r1_marks'          => '',
            'r2_marks'          => '',
            'clusters'          => [],
            'structural_clusters' => [],
            'radical_indexes'   => [],
            'root'              => '',
        ];
    }
/**
 * ============================================================
 * FUNCTION 4
 * Extract r0
 * ============================================================
 *
 * Extracts the radical immediately preceding r1.
 *
 * Important index rule:
 *
 * Structural indexes such as r1_index refer to the unexpanded
 * cluster collection. They must not be applied directly to the
 * expanded-skeleton letters array, because shadda expansion adds
 * an extra letter and changes later indexes.
 *
 * Source priority:
 *
 * 1. structural_clusters
 * 2. clusters
 * 3. letters and marks arrays as a compatibility fallback
 *
 * Structural positions identified in skip_indexes are ignored.
 *
 * Adds or recalculates:
 *
 * - r0
 * - r0_index
 * - r0_marks
 *
 * Unrelated analysis fields are preserved.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_extract_r0(array $analysis): array
{
    /*
     * Preserve the complete cumulative analysis.
     */
    $result = $analysis;

    /*
     * Reset only fields owned by this stage.
     */
    $result['r0'] = '';
    $result['r0_index'] = null;
    $result['r0_marks'] = '';

    /*
     * ------------------------------------------------------------
     * Normalize r1_index.
     * ------------------------------------------------------------
     */
    $r1Index = null;

    if (
        isset($analysis['r1_index'])
        && is_int($analysis['r1_index'])
    ) {
        $r1Index = $analysis['r1_index'];
    } elseif (
        isset($analysis['r1_index'])
        && is_string($analysis['r1_index'])
        && ctype_digit($analysis['r1_index'])
    ) {
        $r1Index = (int) $analysis['r1_index'];
    } elseif (
        isset($analysis['r1_position'])
        && is_int($analysis['r1_position'])
    ) {
        /*
         * Compatibility with older field naming.
         */
        $r1Index = $analysis['r1_position'];
    }

    if ($r1Index === null || $r1Index <= 0) {
        return $result;
    }

    /*
     * ------------------------------------------------------------
     * Normalize indexes that must not become radicals.
     * ------------------------------------------------------------
     */
    $skipLookup = array();

    if (
        isset($analysis['skip_indexes'])
        && is_array($analysis['skip_indexes'])
    ) {
        foreach ($analysis['skip_indexes'] as $skipIndex) {
            if (is_int($skipIndex) && $skipIndex >= 0) {
                $skipLookup[$skipIndex] = true;
                continue;
            }

            if (
                is_string($skipIndex)
                && ctype_digit($skipIndex)
            ) {
                $skipLookup[(int) $skipIndex] = true;
            }
        }
    }

    /*
     * Include individually reported structural positions.
     */
    foreach (
        array(
            'vn_index',
            'vn_position',
            'vm_index',
            'vm_position',
        ) as $positionField
    ) {
        if (
            isset($analysis[$positionField])
            && is_int($analysis[$positionField])
            && $analysis[$positionField] >= 0
        ) {
            $skipLookup[$analysis[$positionField]] = true;
        }
    }

    /*
     * Support form-sign indexes when a previous stage supplies
     * them as a list.
     */
    foreach (
        array(
            'fs_indexes',
            'form_sign_indexes',
        ) as $indexListField
    ) {
        if (
            !isset($analysis[$indexListField])
            || !is_array($analysis[$indexListField])
        ) {
            continue;
        }

        foreach ($analysis[$indexListField] as $skipIndex) {
            if (is_int($skipIndex) && $skipIndex >= 0) {
                $skipLookup[$skipIndex] = true;
            } elseif (
                is_string($skipIndex)
                && ctype_digit($skipIndex)
            ) {
                $skipLookup[(int) $skipIndex] = true;
            }
        }
    }

    /*
     * Arabic combining marks cannot independently represent a
     * radical.
     */
    $combiningMarkPattern =
        '/^[\x{0610}-\x{061A}'
        . '\x{064B}-\x{065F}'
        . '\x{0670}'
        . '\x{06D6}-\x{06ED}]+$/u';

    /*
     * Normalize a cluster marks value into one string.
     */
    $marksToString = static function ($marks): string {
        if (is_string($marks)) {
            return $marks;
        }

        if (!is_array($marks)) {
            return '';
        }

        $output = '';

        foreach ($marks as $mark) {
            if (is_string($mark)) {
                $output .= $mark;
            }
        }

        return $output;
    };

    /*
     * Validate and normalize one possible radical.
     */
    $normalizeRadical = static function (
        $letter
    ) use ($combiningMarkPattern): string {
        if (
            !is_string($letter)
            || $letter === ''
            || $letter === ACE_TATWEEL
            || preg_match(
                $combiningMarkPattern,
                $letter
            ) === 1
        ) {
            return '';
        }

        if (function_exists('ace_normalize_radical')) {
            return ace_normalize_radical($letter);
        }

        return in_array(
            $letter,
            array('أ', 'إ', 'ؤ', 'ئ', 'آ'),
            true
        )
            ? 'ء'
            : $letter;
    };

    /*
     * ------------------------------------------------------------
     * Preferred source: unexpanded structural clusters.
     * ------------------------------------------------------------
     */
    $clusters = array();

    if (
        isset($analysis['structural_clusters'])
        && is_array($analysis['structural_clusters'])
        && $analysis['structural_clusters'] !== array()
    ) {
        $clusters = array_values(
            $analysis['structural_clusters']
        );
    } elseif (
        isset($analysis['clusters'])
        && is_array($analysis['clusters'])
        && $analysis['clusters'] !== array()
    ) {
        $clusters = array_values(
            $analysis['clusters']
        );
    }

    if ($clusters !== array()) {
        /*
         * r1_index must belong to this same unexpanded collection.
         */
        if (!array_key_exists($r1Index, $clusters)) {
            return $result;
        }

        /*
         * Search immediately leftward from r1.
         */
        for ($index = $r1Index - 1; $index >= 0; $index--) {
            if (isset($skipLookup[$index])) {
                continue;
            }

            if (
                !isset($clusters[$index])
                || !is_array($clusters[$index])
            ) {
                continue;
            }

            $cluster = $clusters[$index];

            $letter = isset($cluster['letter'])
                && is_string($cluster['letter'])
                    ? $cluster['letter']
                    : '';

            /*
             * Compatibility with clusters that contain only text.
             */
            if (
                $letter === ''
                && isset($cluster['text'])
                && is_string($cluster['text'])
            ) {
                $plainCluster = function_exists(
                    'ace_remove_diacritics'
                )
                    ? ace_remove_diacritics(
                        $cluster['text']
                    )
                    : preg_replace(
                        '/[\x{0610}-\x{061A}'
                        . '\x{064B}-\x{065F}'
                        . '\x{0670}'
                        . '\x{06D6}-\x{06ED}]/u',
                        '',
                        $cluster['text']
                    );

                $letter = is_string($plainCluster)
                    ? $plainCluster
                    : '';
            }

            $radical = $normalizeRadical($letter);

            if ($radical === '') {
                continue;
            }

            $result['r0'] = $radical;
            $result['r0_index'] = $index;
            $result['r0_marks'] = $marksToString(
                $cluster['marks'] ?? ''
            );

            return $result;
        }

        return $result;
    }

    /*
     * ------------------------------------------------------------
     * Compatibility fallback: letters and marks arrays.
     * ------------------------------------------------------------
     *
     * This source should be used only when no cluster collection
     * exists. It is unsafe when letters contains expanded shadda
     * consonants while r1_index was calculated from clusters.
     */
    if (
        !isset($analysis['letters'])
        || !is_array($analysis['letters'])
    ) {
        return $result;
    }

    $letters = array_values($analysis['letters']);

    $marks = (
        isset($analysis['marks'])
        && is_array($analysis['marks'])
    )
        ? array_values($analysis['marks'])
        : array();

    if (!array_key_exists($r1Index, $letters)) {
        return $result;
    }

    for ($index = $r1Index - 1; $index >= 0; $index--) {
        if (isset($skipLookup[$index])) {
            continue;
        }

        if (!array_key_exists($index, $letters)) {
            continue;
        }

        $radical = $normalizeRadical(
            $letters[$index]
        );

        if ($radical === '') {
            continue;
        }

        $result['r0'] = $radical;
        $result['r0_index'] = $index;
        $result['r0_marks'] = array_key_exists(
            $index,
            $marks
        )
            ? $marksToString($marks[$index])
            : '';

        return $result;
    }

    return $result;
}
/**
 * Assign r2, r1 and r0 by scanning backward from the end of the stem.
 *
 * Structural long vowels and form markers are not automatically treated
 * as radicals. A long ا between two consonants is attached to the vowel
 * slot preceding r1, as in Form III and Form VI.
 */
function ace_assign_root_positions_bu(array $analysis): array
{
    $clusters = $analysis['structural_clusters']
        ?? $analysis['clusters']
        ?? array();

    $analysis['r0']       = '';
    $analysis['v1']       = '';
    $analysis['r1']       = '';
    $analysis['v2']       = '';
    $analysis['r2']       = '';
    $analysis['r0_marks'] = '';
    $analysis['r1_marks'] = '';
    $analysis['r2_marks'] = '';

    if ($clusters === array()) {
        return $analysis;
    }

    /*
     * Normalize cluster entries.
     */
    $normalized = array();

    foreach ($clusters as $index => $cluster) {
        if (is_string($cluster)) {
            $normalized[] = array(
                'index'  => $index,
                'letter' => $cluster,
                'marks'  => '',
                'text'   => $cluster,
            );

            continue;
        }

        if (!is_array($cluster)) {
            continue;
        }

        $letter = (string) ($cluster['letter'] ?? '');
        $marks  = (string) ($cluster['marks'] ?? '');
        $text   = (string) ($cluster['text'] ?? ($letter . $marks));

        if ($letter === '') {
            continue;
        }

        $normalized[] = array(
            'index'  => $index,
            'letter' => $letter,
            'marks'  => $marks,
            'text'   => $text,
        );
    }

    if ($normalized === array()) {
        return $analysis;
    }

    $vnIndex = null;
    $vmIndex = null;
    $fsIndexes = array();

    /*
     * Locate the imperfect person marker.
     */
    if (
        isset($normalized[0]) &&
        in_array($normalized[0]['letter'], array('ي', 'ت', 'أ', 'ن'), true)
    ) {
        $vnIndex = 0;
        $analysis['vn'] = $normalized[0]['letter'];
    }

    /*
     * Locate a structural ت after the imperfect marker.
     *
     * This covers forms V and VI:
     * يَتَدَرَّسُ
     * يَتَدَارَسُ
     */
    if (
        $vnIndex === 0 &&
        isset($normalized[1]) &&
        $normalized[1]['letter'] === 'ت'
    ) {
        $vmIndex = 1;
        $analysis['vm'] = 'ت';
    }

    /*
     * Mark form-sign clusters that must not be selected as radicals.
     */
    $fs = (string) ($analysis['fs'] ?? '');

    if ($fs !== '') {
        $fsLetters = preg_split('//u', $fs, -1, PREG_SPLIT_NO_EMPTY);
        $start = $vnIndex === null ? 0 : 1;

        foreach ($fsLetters as $fsLetter) {
            for ($i = $start, $count = count($normalized); $i < $count; $i++) {
                if (
                    !isset($fsIndexes[$i]) &&
                    $normalized[$i]['letter'] === $fsLetter
                ) {
                    $fsIndexes[$i] = true;
                    $start = $i + 1;
                    break;
                }
            }
        }
    }

    /*
     * Scan from the end and collect three radical consonants.
     */
    $radicals = array();
    $longVowels = array('ا', 'ى', 'و', 'ي');

    for ($i = count($normalized) - 1; $i >= 0; $i--) {
        $cluster = $normalized[$i];
        $letter  = $cluster['letter'];

        if ($i === $vnIndex || $i === $vmIndex || isset($fsIndexes[$i])) {
            continue;
        }

        /*
         * A bare medial long vowel is structural when consonants occur
         * on both sides. It must not displace a true radical.
         *
         * Example:
         * ي ت د ا ر س
         *       ا = long vowel, not r0
         */
        if (
            in_array($letter, $longVowels, true) &&
            $cluster['marks'] === '' &&
            ace_is_structural_medial_long_vowel(
                $normalized,
                $i,
                $vnIndex,
                $vmIndex,
                $fsIndexes
            )
        ) {
            continue;
        }

        $radicals[] = array(
            'cluster_index' => $i,
            'letter'        => ace_normalize_radical($letter),
            'marks'         => $cluster['marks'],
            'text'          => $cluster['text'],
        );

        if (count($radicals) === 3) {
            break;
        }
    }

    /*
     * Radicals were collected as r2, r1, r0.
     */
    if (isset($radicals[0])) {
        $analysis['r2']       = $radicals[0]['letter'];
        $analysis['r2_marks'] = $radicals[0]['marks'];
    }

    if (isset($radicals[1])) {
        $analysis['r1']       = $radicals[1]['letter'];
        $analysis['r1_marks'] = $radicals[1]['marks'];
    }

    if (isset($radicals[2])) {
        $analysis['r0']       = $radicals[2]['letter'];
        $analysis['r0_marks'] = $radicals[2]['marks'];
    }

    /*
     * Build vowel spans between the assigned radical positions.
     */
    if (isset($radicals[2], $radicals[1])) {
        $analysis['v1'] = ace_collect_interradical_material(
            $normalized,
            $radicals[2]['cluster_index'],
            $radicals[1]['cluster_index']
        );
    }

    if (isset($radicals[1], $radicals[0])) {
        $analysis['v2'] = ace_collect_interradical_material(
            $normalized,
            $radicals[1]['cluster_index'],
            $radicals[0]['cluster_index']
        );
    }

    $analysis['r1_repeat'] = ace_cluster_has_shadda(
        $radicals[1]['marks'] ?? ''
    );

    $analysis['r2_repeat'] = ace_cluster_has_shadda(
        $radicals[0]['marks'] ?? ''
    );

    $analysis['root'] = implode('', array_filter(
        array(
            $analysis['r0'],
            $analysis['r1'],
            $analysis['r2'],
        ),
        static fn(string $value): bool => $value !== ''
    ));

    $analysis['root_letters'] = array_values(array_filter(
        array(
            $analysis['r0'],
            $analysis['r1'],
            $analysis['r2'],
        ),
        static fn(string $value): bool => $value !== ''
    ));

    return $analysis;
}
/**
 * ============================================================
 * FUNCTION 6
 * Extract pattern features
 * ============================================================
 *
 * Extracts structural features used for Arabic verb-form
 * classification.
 *
 * This function is intentionally tolerant of failed or partial
 * radical extraction, especially with:
 *
 * - assimilated roots
 * - hollow roots
 * - defective roots
 * - doubly weak roots
 * - Form VIII assimilation
 *
 * It does not modify r0, r1, or r2.
 */
function ace_extract_pattern_features(array $clusters)
{
    $features = array(
        /*
         * Previously extracted prefix positions.
         */
        'vn' => '',
        'vm' => '',
        'fs' => '',

        /*
         * Radical repetition.
         */
        'r1_repeat' => false,
        'r2_repeat' => false,

        /*
         * Long-vowel positions.
         */
        'has_long_a_after_r1' => false,
        'has_long_a_after_vm' => false,

        /*
         * Derived-form markers.
         */
        'has_form8_t' => false,
        'has_form7_n' => false,
        'has_ist_prefix' => false,

        /*
         * Initial hamza.
         */
        'starts_with_hamzat_wasl' => false,
        'starts_with_hamzat_qat' => false,
    );

    /*
     * --------------------------------------------------------
     * 1. Copy previously extracted structural values
     * --------------------------------------------------------
     */
    foreach (array('vn', 'vm', 'fs') as $key) {
        if (
            isset($clusters[$key]) &&
            is_string($clusters[$key])
        ) {
            $features[$key] = $clusters[$key];
        }
    }

    /*
     * --------------------------------------------------------
     * 2. Copy radical repetition flags
     * --------------------------------------------------------
     *
     * Important:
     * Use the same key names initialized above.
     */
    if (array_key_exists('r1_repeat', $clusters)) {
        $features['r1_repeat'] =
            ($clusters['r1_repeat'] === true);
    }

    if (array_key_exists('r2_repeat', $clusters)) {
        $features['r2_repeat'] =
            ($clusters['r2_repeat'] === true);
    }

    /*
     * --------------------------------------------------------
     * 3. Obtain the expanded letter sequence
     * --------------------------------------------------------
     *
     * Prefer "letters" because shadda should already have been
     * expanded there:
     *
     * اِتَّقَى
     * ا ت ت ق ى
     *
     * اِوْقَيَّ
     * ا و ق ي ي
     */
    $letters = array();

    if (
        isset($clusters['letters']) &&
        is_array($clusters['letters'])
    ) {
        foreach ($clusters['letters'] as $letter) {
            if (is_string($letter) && $letter !== '') {
                $letters[] = ace_normalize_pattern_letter($letter);
            }
        }
    }

    /*
     * Fall back to structural clusters when "letters" is absent.
     */
    if (
        empty($letters) &&
        isset($clusters['structural_clusters']) &&
        is_array($clusters['structural_clusters'])
    ) {
        foreach ($clusters['structural_clusters'] as $cluster) {
            if (!is_array($cluster)) {
                continue;
            }

            if (
                isset($cluster['letter']) &&
                is_string($cluster['letter']) &&
                $cluster['letter'] !== ''
            ) {
                $letter = ace_normalize_pattern_letter(
                    $cluster['letter']
                );

                $letters[] = $letter;

                /*
                 * Expand shadda structurally.
                 */
                $marks = isset($cluster['marks'])
                    ? (string) $cluster['marks']
                    : '';

                if (mb_strpos($marks, 'ّ', 0, 'UTF-8') !== false) {
                    $letters[] = $letter;
                }
            }
        }
    }

    if (empty($letters)) {
        return $features;
    }

    $letterCount = count($letters);
    $firstLetter = $letters[0];

    /*
     * --------------------------------------------------------
     * 4. Detect the initial hamza type
     * --------------------------------------------------------
     *
     * Bare initial ا is treated as hamzat al-waṣl.
     * أ or إ is treated as hamzat al-qaṭʿ.
     */
    if ($firstLetter === 'ا') {
        $features['starts_with_hamzat_wasl'] = true;
    }

    if (
        $firstLetter === 'أ' ||
        $firstLetter === 'إ' ||
        $firstLetter === 'ء'
    ) {
        $features['starts_with_hamzat_qat'] = true;
    }

    /*
     * --------------------------------------------------------
     * 5. Detect Form X prefix: است
     * --------------------------------------------------------
     */
    if (
        $letterCount >= 3 &&
        $letters[0] === 'ا' &&
        $letters[1] === 'س' &&
        $letters[2] === 'ت'
    ) {
        $features['has_ist_prefix'] = true;

        if ($features['fs'] === '') {
            $features['fs'] = 'است';
        }
    }

    /*
     * --------------------------------------------------------
     * 6. Detect Form VII ن marker
     * --------------------------------------------------------
     *
     * Detect from the surface sequence rather than depending
     * entirely on fs extraction.
     *
     * Examples:
     *
     * اِنْكَتَبَ  => ا ن ك ت ب
     * اِنْوَقَى   => ا ن و ق ى
     *
     * In اِنْوَقَى, ن must be recognized as the Form VII
     * marker even though the root is doubly weak: و ق ي.
     */
    if (
        $letterCount >= 2 &&
        $letters[0] === 'ا' &&
        $letters[1] === 'ن'
    ) {
        $features['has_form7_n'] = true;

        if ($features['fs'] === '') {
            $features['fs'] = 'ن';
        }
    }

    if ($features['fs'] === 'ن' || $features['fs'] === 'نْ') {
        $features['has_form7_n'] = true;
    }

    /*
     * --------------------------------------------------------
     * 7. Detect Form VIII ت marker
     * --------------------------------------------------------
     *
     * Normal Form VIII:
     *
     * اِكْتَتَبَ
     * ا ك ت ت ب
     *
     * Weak assimilated Form VIII:
     *
     * اِتَّقَى
     * ا ت ت ق ى
     *
     * The root is و ق ي, but the initial و has assimilated into
     * the Form VIII ت. Therefore the surface sequence starts:
     *
     * ا + تّ
     *
     * which expands to:
     *
     * ا + ت + ت
     */
    if ($features['vm'] === 'ت') {
        $features['has_form8_t'] = true;
    }

    /*
     * Ordinary Form VIII internal ت.
     *
     * Initial ا, then first radical, then inserted ت.
     */
    if (
        $letterCount >= 3 &&
        $letters[0] === 'ا' &&
        $letters[2] === 'ت'
    ) {
        $features['has_form8_t'] = true;
    }

    /*
     * Assimilated initial-weak Form VIII.
     *
     * اِتَّقَى => ا ت ت ق ى
     *
     * The repeated ت represents:
     * - assimilated initial و
     * - Form VIII inserted ت
     */
    if (
        $letterCount >= 3 &&
        $letters[0] === 'ا' &&
        $letters[1] === 'ت' &&
        $letters[2] === 'ت'
    ) {
        $features['has_form8_t'] = true;

        if ($features['vm'] === '') {
            $features['vm'] = 'ت';
        }
    }

    /*
     * Other Form VIII assimilation letters.
     *
     * In roots beginning with د، ذ، ز، ص، ض، ط، ظ، ث,
     * the inserted ت may assimilate or change its surface form.
     *
     * A doubled consonant immediately after initial ا is useful
     * evidence for an assimilated Form VIII structure.
     */
    $form8AssimilationLetters = array(
        'ت', 'ث', 'د', 'ذ', 'ز', 'ص', 'ض', 'ط', 'ظ'
    );

    if (
        $letterCount >= 3 &&
        $letters[0] === 'ا' &&
        $letters[1] === $letters[2] &&
        in_array(
            $letters[1],
            $form8AssimilationLetters,
            true
        )
    ) {
        $features['has_form8_t'] = true;
    }

    /*
     * --------------------------------------------------------
     * 8. Detect long ا after r1
     * --------------------------------------------------------
     *
     * Prefer explicit indexes when they are reliable.
     */
    if (
        isset($clusters['r1_index']) &&
        is_int($clusters['r1_index'])
    ) {
        $nextIndex = $clusters['r1_index'] + 1;

        if (
            isset($letters[$nextIndex]) &&
            ace_is_long_a_letter($letters[$nextIndex])
        ) {
            $features['has_long_a_after_r1'] = true;
        }
    }

    /*
     * Fall back to the radical value when indexes failed.
     */
    if (
        !$features['has_long_a_after_r1'] &&
        isset($clusters['r1']) &&
        is_string($clusters['r1']) &&
        $clusters['r1'] !== ''
    ) {
        $r1 = ace_normalize_pattern_letter($clusters['r1']);

        for ($i = 0; $i < $letterCount - 1; $i++) {
            if (
                $letters[$i] === $r1 &&
                ace_is_long_a_letter($letters[$i + 1])
            ) {
                $features['has_long_a_after_r1'] = true;
                break;
            }
        }
    }

    /*
     * --------------------------------------------------------
     * 9. Detect long ا after vm
     * --------------------------------------------------------
     */
    if ($features['vm'] !== '') {
        $vm = ace_normalize_pattern_letter($features['vm']);

        for ($i = 0; $i < $letterCount - 1; $i++) {
            if (
                $letters[$i] === $vm &&
                ace_is_long_a_letter($letters[$i + 1])
            ) {
                $features['has_long_a_after_vm'] = true;
                break;
            }
        }
    }

    return $features;
}

/**
 * Normalize a letter for structural pattern comparison.
 *
 * This normalization is deliberately conservative:
 * hamzat al-waṣl and hamzat al-qaṭʿ remain distinguishable.
 */
function ace_normalize_pattern_letter($letter)
{
    if (!is_string($letter)) {
        return '';
    }

    /*
     * Remove Arabic combining marks if a marked letter was
     * accidentally supplied instead of a plain letter.
     */
    $letter = preg_replace(
        '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
        '',
        $letter
    );

    return $letter;
}

/**
 * Determine whether a letter represents structural long ā.
 */
function ace_is_long_a_letter($letter)
{
    return in_array(
        $letter,
        array('ا', 'ى', 'ٰ'),
        true
    );
}
/**
 * ============================================================
 * FUNCTION 7
 * Extract vm
 * ============================================================
 *
 * Extracts the internal derivational consonant associated with
 * the first root radical.
 *
 * Current structural interpretation:
 *
 * Form VII:
 *
 *     اِنْكَتَبَ
 *     ا + ن + ك + ت + ب
 *         vm  r0  r1  r2
 *
 *     The marker ن occurs immediately before r0.
 *
 * Form VIII:
 *
 *     اِكْتَتَبَ
 *     ا + ك + ت + ت + ب
 *         r0  vm  r1  r2
 *
 *     The Form VIII marker normally occurs immediately after r0.
 *
 * Recognized Form VIII surface markers:
 *
 *     ت
 *     ط
 *     د
 *
 * Examples:
 *
 *     اِكْتَتَبَ   regular ت
 *     اِضْطَرَبَ   assimilated ط
 *     اِدَّكَرَ    assimilated د
 *
 * Important exclusions:
 *
 * - Form V and Form VI initial ت belongs to vn.
 * - Form X is represented by fs = است or ست.
 * - Forms I–IV and IX normally have no vm.
 *
 * Supported calling styles:
 *
 *     ace_extract_vm($analysis)
 *     ace_extract_vm($markedStem, $analysis)
 *
 * Adds or resets:
 *
 * - vm
 * - vm_position
 * - vm_surface
 * - vm_underlying
 *
 * @param mixed                $input
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_extract_vm(
    $input,
    array $analysis = array()
): array {
    /*
     * Normalize the supported calling styles.
     */
    if (is_array($input)) {
        $result = $input;
    } else {
        $result = $analysis;

        if (is_string($input) && $input !== '') {
            $result['marked_stem'] = $input;
        }
    }

    /*
     * Reset only fields owned by this function.
     */
    $result['vm'] = '';
    $result['vm_position'] = null;
    $result['vm_surface'] = '';
    $result['vm_underlying'] = '';

    /*
     * Obtain the best available cluster list.
     */
    if (
        isset($result['structural_clusters'])
        && is_array($result['structural_clusters'])
    ) {
        $clusters = array_values(
            $result['structural_clusters']
        );
    } elseif (
        isset($result['clusters'])
        && is_array($result['clusters'])
    ) {
        $clusters = array_values(
            $result['clusters']
        );
    } else {
        return $result;
    }

    if ($clusters === array()) {
        return $result;
    }

    /*
     * Convert structural clusters to normalized base letters.
     */
    $letters = array();

    foreach ($clusters as $cluster) {
        $letter = '';

        if (
            is_array($cluster)
            && isset($cluster['letter'])
            && is_string($cluster['letter'])
        ) {
            $letter = $cluster['letter'];
        } elseif (is_string($cluster)) {
            $letter = $cluster;
        }

        if (
            $letter !== ''
            && function_exists('ace_normalize_radical')
        ) {
            $letter = ace_normalize_radical($letter);
        }

        $letters[] = $letter;
    }

    /*
     * Locate r0.
     *
     * Accept both the current and older field names.
     */
    $r0Position = null;

    if (
        array_key_exists('r0_position', $result)
        && is_int($result['r0_position'])
    ) {
        $r0Position = $result['r0_position'];
    } elseif (
        array_key_exists('r0_index', $result)
        && is_int($result['r0_index'])
    ) {
        $r0Position = $result['r0_index'];
    }

    if (
        $r0Position === null
        || $r0Position < 0
        || !isset($letters[$r0Position])
    ) {
        return $result;
    }

    /*
     * Form X exclusion.
     *
     * Form X uses:
     *
     *     است
     *
     * or, after a finite-person prefix:
     *
     *     ست
     *
     * Its ت must not be classified as vm.
     */
    $fs = isset($result['fs'])
        ? (string) $result['fs']
        : '';

    if (
        $fs === 'است'
        || $fs === 'ست'
        || !empty($result['has_ist_prefix'])
        || !empty($result['is_form_x'])
        || (
            isset($result['form'])
            && strtoupper((string) $result['form']) === 'X'
        )
    ) {
        return $result;
    }

    /*
     * Also detect the Form X sequence directly when fs has not
     * yet been assigned.
     */
    $sequenceStart = 0;

    if (
        isset($letters[0])
        && in_array(
            $letters[0],
            array('ي', 'ت', 'ء', 'ن'),
            true
        )
    ) {
        $sequenceStart = 1;
    }

    $hasFormXSequence = (
        isset(
            $letters[$sequenceStart],
            $letters[$sequenceStart + 1]
        )
        && $letters[$sequenceStart] === 'س'
        && $letters[$sequenceStart + 1] === 'ت'
        && $r0Position === $sequenceStart + 2
    );

    if ($hasFormXSequence) {
        return $result;
    }

    /*
     * Form VII.
     *
     * The derivational ن must occur immediately before r0.
     */
    $beforeR0 = $r0Position - 1;

    if (
        $beforeR0 >= 0
        && isset($letters[$beforeR0])
        && $letters[$beforeR0] === 'ن'
    ) {
        /*
         * Use structural evidence when available.
         *
         * The positional relationship is sufficient when the
         * form-detection fields have not yet been produced.
         */
        $hasForm7Evidence = (
            !empty($result['has_form7_n'])
            || !empty($result['is_form_vii'])
            || (
                isset($result['form'])
                && strtoupper((string) $result['form']) === 'VII'
            )
            || $r0Position >= 1
        );

        if ($hasForm7Evidence) {
            $result['vm'] = 'ن';
            $result['vm_position'] = $beforeR0;
            $result['vm_surface'] = 'ن';
            $result['vm_underlying'] = 'ن';

            return $result;
        }
    }

    /*
     * Form VIII.
     *
     * The derivational marker normally occurs immediately
     * after r0.
     */
    $afterR0 = $r0Position + 1;

    if (
        !isset($letters[$afterR0])
        || !in_array(
            $letters[$afterR0],
            array('ت', 'ط', 'د'),
            true
        )
    ) {
        return $result;
    }

    $surfaceMarker = $letters[$afterR0];

    /*
     * Prefer explicit Form VIII evidence.
     *
     * When form detection has not yet run, accept the structural
     * position only when at least two later radical positions
     * remain. This prevents a final radical ت, ط, or د from being
     * incorrectly classified as vm.
     */
    $hasTwoFollowingSlots = (
        isset(
            $letters[$afterR0 + 1],
            $letters[$afterR0 + 2]
        )
    );

    $hasForm8Evidence = (
        !empty($result['has_form8_t'])
        || !empty($result['form8_marker'])
        || !empty($result['form8_assimilation'])
        || !empty($result['is_form_viii'])
        || (
            isset($result['form'])
            && strtoupper((string) $result['form']) === 'VIII'
        )
        || $hasTwoFollowingSlots
    );

    if (!$hasForm8Evidence) {
        return $result;
    }

    /*
     * Store both the surface realization and the underlying
     * Form VIII ت.
     */
    $result['vm'] = $surfaceMarker;
    $result['vm_position'] = $afterR0;
    $result['vm_surface'] = $surfaceMarker;
    $result['vm_underlying'] = 'ت';

    return $result;
}
/**
 * ============================================================
 * FUNCTION 8
 * Extract vn
 * ============================================================
 *
 * vn is an initial derivational marker.
 *
 * It is not:
 * - an imperfect person marker;
 * - an internal Form VIII marker;
 * - part of the lexical root.
 *
 * Recognized values:
 *
 * Form V:
 *     تَفَعَّلَ
 *     vn = ت
 *
 * Form VI:
 *     تَفَاعَلَ
 *     vn = ت
 *
 * Form IV perfect or imperative:
 *     أَفْعَلَ
 *     أَفْعِلْ
 *     vn = أ
 *
 * Imperfect person markers remain excluded:
 *
 *     يَفْعَلُ
 *     تَفْعَلُ
 *     أَفْعَلُ
 *     نَفْعَلُ
 *
 * Forms VII, VIII, and X are identified through fs, vm,
 * or their structural feature flags.
 *
 * Supported calling styles:
 *
 *     ace_extract_vn($analysis)
 *     ace_extract_vn($markedStem, $analysis)
 *
 * Adds:
 * - vn
 * - vn_position
 *
 * @param mixed                $input
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_extract_vn(
    $input,
    array $analysis = array()
): array {
    /*
     * Normalize supported calling styles.
     */
    if (is_array($input)) {
        $result = $input;
    } else {
        $result = $analysis;

        if (is_string($input) && $input !== '') {
            $result['marked_stem'] = $input;
        }
    }

    /*
     * Reset only fields owned by this stage.
     */
    $result['vn'] = '';
    $result['vn_position'] = null;

    /*
     * Obtain structural clusters.
     */
    if (
        isset($result['structural_clusters'])
        && is_array($result['structural_clusters'])
    ) {
        $clusters = array_values(
            $result['structural_clusters']
        );
    } elseif (
        isset($result['clusters'])
        && is_array($result['clusters'])
    ) {
        $clusters = array_values(
            $result['clusters']
        );
    } else {
        return $result;
    }

    if ($clusters === array()) {
        return $result;
    }

    /*
     * Locate r0.
     */
    $r0Position = null;

    if (
        isset($result['r0_position'])
        && is_int($result['r0_position'])
    ) {
        $r0Position = $result['r0_position'];
    } elseif (
        isset($result['r0_index'])
        && is_int($result['r0_index'])
    ) {
        $r0Position = $result['r0_index'];
    }

    /*
     * vn must occur before r0.
     */
    if (
        $r0Position === null
        || $r0Position <= 0
        || !isset($clusters[$r0Position - 1])
        || !is_array($clusters[$r0Position - 1])
    ) {
        return $result;
    }

    $candidatePosition = $r0Position - 1;
    $candidateCluster = $clusters[$candidatePosition];

    $candidate = isset($candidateCluster['letter'])
        && is_string($candidateCluster['letter'])
            ? $candidateCluster['letter']
            : '';

    if ($candidate === '') {
        return $result;
    }

    $candidateMarks = isset($candidateCluster['marks'])
        && is_string($candidateCluster['marks'])
            ? $candidateCluster['marks']
            : '';

    /*
     * Obtain previously extracted structural evidence.
     */
    $fs = isset($result['fs'])
        && is_string($result['fs'])
            ? $result['fs']
            : '';

    $v1 = isset($result['v1'])
        && is_string($result['v1'])
            ? $result['v1']
            : '';

    $r1Repeat = !empty($result['r1_repeat']);

    /*
     * Forms VII, VIII, and X do not use vn.
     *
     * Do not infer these forms merely from adjacent letters.
     * A sequence such as ي + س + ت can belong to a Form I
     * root such as س-ت-ر.
     */
    $isFormXStructure = (
        $fs === 'است'
        || $fs === 'ست'
        || !empty($result['has_ist_prefix'])
    );

    $isFormVIIIStructure = (
        !empty($result['has_form8_t'])
        || (
            isset($result['vm'])
            && $result['vm'] === 'ت'
        )
    );

    $isFormVIIStructure = (
        $fs === 'ن'
        || $fs === 'ان'
        || !empty($result['has_form7_n'])
    );

    if (
        $isFormXStructure
        || $isFormVIIIStructure
        || $isFormVIIStructure
    ) {
        return $result;
    }

    /*
     * Form V:
     *
     *     ت + r0 + repeated r1
     */
    if (
        $candidate === 'ت'
        && $r1Repeat
    ) {
        $result['vn'] = 'ت';
        $result['vn_position'] = $candidatePosition;

        return $result;
    }

    /*
     * Form VI:
     *
     *     ت + r0 + ا + r1 + r2
     *
     * v1 represents the long vowel between r0 and r1.
     */
    $hasFormVILongAlif = (
        $v1 === 'ا'
        || !empty($result['long_a_after_r0'])
    );

    if (
        $candidate === 'ت'
        && $hasFormVILongAlif
    ) {
        $result['vn'] = 'ت';
        $result['vn_position'] = $candidatePosition;

        return $result;
    }

    /*
     * Form IV perfect or imperative:
     *
     *     أ + r0 + r1 + r2
     *
     * A normalized cluster may contain ء instead of أ.
     * Store the structural marker consistently as أ.
     */
    $isHamzaCandidate = in_array(
        $candidate,
        array('أ', 'ء'),
        true
    );

    if (!$isHamzaCandidate) {
        return $result;
    }

    /*
     * Form IV vn must be the initial structural cluster.
     *
     * This prevents a lexical or internal hamza from being
     * classified as a derivational marker.
     */
    if ($candidatePosition !== 0) {
        return $result;
    }

    /*
     * Positive Form IV evidence may come from:
     *
     * - Function 6 detecting hamzat al-qat;
     * - an explicitly identified Form IV candidate;
     * - the surface fatḥa on initial أ.
     *
     * Repetition and long ā evidence would indicate another
     * derived pattern and therefore block Form IV assignment.
     */
    $hasFatha = (
        mb_strpos($candidateMarks, 'َ') !== false
    );

    $hasForm4Candidate = false;

    if (
        isset($result['form_candidate'])
        && in_array(
            $result['form_candidate'],
            array(4, '4', 'IV'),
            true
        )
    ) {
        $hasForm4Candidate = true;
    }

    if (
        isset($result['form_candidates'])
        && is_array($result['form_candidates'])
    ) {
        foreach ($result['form_candidates'] as $formCandidate) {
            if (
                in_array(
                    $formCandidate,
                    array(4, '4', 'IV'),
                    true
                )
            ) {
                $hasForm4Candidate = true;
                break;
            }
        }
    }

    $hasForm4Evidence = (
        (
            !empty($result['has_hamzat_qat'])
            || $hasForm4Candidate
            || $hasFatha
        )
        && !$r1Repeat
        && !$hasFormVILongAlif
    );

    if ($hasForm4Evidence) {
        $result['vn'] = 'أ';
        $result['vn_position'] = $candidatePosition;
    }

    return $result;
}
/**
 * ============================================================
 * FUNCTION 9
 * Detect Arabic Verb Form
 * ============================================================
 *
 * Detects Forms I-X from previously extracted structural
 * features while preserving the complete analysis row.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
function ace_detect_form(array $analysis): array
{
    $result = $analysis;

    $result['form'] = '';
    $result['form_reason'] = '';

    $vn = isset($result['vn'])
        ? (string) $result['vn']
        : '';

    $vm = isset($result['vm'])
        ? (string) $result['vm']
        : '';

    $fs = isset($result['fs'])
        ? (string) $result['fs']
        : '';

    $v1 = isset($result['v1'])
        ? (string) $result['v1']
        : '';

    $r1Repeat = !empty($result['r1_repeat']);
    $r2Repeat = !empty($result['r2_repeat']);

    $hasForm8T = !empty($result['has_form8_t']);
    $hasForm7N = !empty($result['has_form7_n']);
    $hasIstPrefix = !empty($result['has_ist_prefix']);

    /*
     * Obtain structural letters for sequence-based detection.
     */
    $clusters = array();

    if (
        isset($result['structural_clusters'])
        && is_array($result['structural_clusters'])
    ) {
        $clusters = array_values(
            $result['structural_clusters']
        );
    } elseif (
        isset($result['clusters'])
        && is_array($result['clusters'])
    ) {
        $clusters = array_values(
            $result['clusters']
        );
    }

    $letters = array();

    foreach ($clusters as $cluster) {
        $letters[] = (
            is_array($cluster)
            && isset($cluster['letter'])
            && is_string($cluster['letter'])
        )
            ? $cluster['letter']
            : '';
    }

    /*
     * Detect an optional imperfect person marker.
     */
    $sequenceStart = 0;

    if (
        isset($letters[0])
        && in_array(
            $letters[0],
            array('ي', 'ت', 'أ', 'ن'),
            true
        )
    ) {
        $sequenceStart = 1;
    }

    /*
     * Form X:
     *
     * Perfect:
     *     ا + س + ت + r0 + r1 + r2
     *
     * Imperfect:
     *     person marker + س + ت + r0 + r1 + r2
     */
    $hasVisibleFormXSequence = (
        isset(
            $letters[$sequenceStart],
            $letters[$sequenceStart + 1]
        )
        && $letters[$sequenceStart] === 'س'
        && $letters[$sequenceStart + 1] === 'ت'
    );

    if (
        $hasIstPrefix
        || $fs === 'است'
        || $fs === 'ست'
        || $hasVisibleFormXSequence
    ) {
        $result['form'] = 'X';
        $result['form_reason'] =
            'Form X است or ست sequence detected';

        return $result;
    }

    /*
     * Form IX:
     *
     * Check before Forms V and IV because imperfect person
     * markers such as ت and أ must not override r2 repetition.
     */
    if ($r2Repeat) {
        $result['form'] = 'IX';
        $result['form_reason'] =
            'Third radical is repeated';

        return $result;
    }

    /*
     * Form VII:
     *
     * The current pipeline may store fs as either ن or ان.
     */
    if (
        $hasForm7N
        || $fs === 'ن'
        || $fs === 'ان'
        || $vm === 'ن'
    ) {
        $result['form'] = 'VII';
        $result['form_reason'] =
            'Form VII ن marker detected before r0';

        return $result;
    }

    /*
     * Form VIII:
     *
     * Do not classify solely from an arbitrary vm = ت.
     * Require Form VIII evidence or an assimilated marker.
     */
    if (
        $hasForm8T
        || !empty($result['form8_marker'])
        || !empty($result['form8_assimilation'])
        || in_array($vm, array('ط', 'د'), true)
        || (
            $vm === 'ت'
            && $vn === ''
            && $fs === ''
        )
    ) {
        $result['form'] = 'VIII';
        $result['form_reason'] =
            'Internal Form VIII marker detected after r0';

        return $result;
    }

    /*
     * Form VI: تَفَاعَلَ
     */
    if ($vn === 'ت' && $v1 === 'ا') {
        $result['form'] = 'VI';
        $result['form_reason'] =
            'Derivational ت with long ا after r0';

        return $result;
    }

    /*
     * Form V: تَفَعَّلَ
     */
    if ($vn === 'ت' && $r1Repeat) {
        $result['form'] = 'V';
        $result['form_reason'] =
            'Derivational ت with repeated second radical';

        return $result;
    }

    /*
     * Form IV: أَفْعَلَ
     */
    if ($vn === 'أ') {
        $result['form'] = 'IV';
        $result['form_reason'] =
            'Initial derivational hamza أ detected';

        return $result;
    }

    /*
     * Form III: فَاعَلَ
     */
    if ($v1 === 'ا') {
        $result['form'] = 'III';
        $result['form_reason'] =
            'Long ا detected after the first radical';

        return $result;
    }

    /*
     * Form II: فَعَّلَ
     */
    if ($r1Repeat) {
        $result['form'] = 'II';
        $result['form_reason'] =
            'Second radical is repeated';

        return $result;
    }

    /*
     * Default: Form I.
     */
    $result['form'] = 'I';
    $result['form_reason'] =
        'No derived-form marker detected';

    return $result;
}
/**
 * ============================================================
 * FUNCTION 12
 * Extract Root Candidates
 * ============================================================
 *
 * Builds one root candidate from the currently assigned radical
 * positions while preserving all earlier analysis fields.
 *
 * Expected fields:
 *
 *     r0
 *     r1
 *     r2
 *
 * Adds:
 *
 *     root
 *     root_letters
 *     root_candidates
 *     candidate_count
 */
function ace_extract_root_candidates(array $analysis): array
{
    /*
     * Preserve every field produced by earlier pipeline stages.
     */
    $result = $analysis;

    /*
     * Reset only fields owned by this stage.
     */
    $result['root'] = '';
    $result['root_letters'] = array();
    $result['root_candidates'] = array();
    $result['candidate_count'] = 0;

    $rootLetters = array();

    /*
     * Collect valid radical values in structural order.
     */
    foreach (array('r0', 'r1', 'r2') as $position) {
        $radical = isset($analysis[$position])
            && is_string($analysis[$position])
                ? trim($analysis[$position])
                : '';

        if ($radical === '') {
            continue;
        }

        /*
         * Normalize hamza variants if the helper is available.
         */
        if (function_exists('ace_normalize_radical')) {
            $radical = ace_normalize_radical($radical);
        }

        $rootLetters[] = $radical;
    }

    /*
     * A normal root candidate requires three radicals.
     *
     * Do not manufacture an incomplete root from one or two
     * extracted positions.
     */
    if (count($rootLetters) !== 3) {
        $result['root_letters'] = $rootLetters;

        return $result;
    }

    $root = implode('', $rootLetters);

    $result['root'] = $root;
    $result['root_letters'] = $rootLetters;
    $result['root_candidates'] = array($root);
    $result['candidate_count'] = 1;

    return $result;
}
/**
 * ============================================================
 * FUNCTION 13
 * Generate Root Candidates
 * ============================================================
 *
 * Validates and generates root candidates from the radical data
 * produced by Function 12 while preserving all earlier fields.
 *
 * Expected fields:
 *
 *     root
 *     root_letters
 *     root_candidates
 *     candidate_count
 *
 * Updates:
 *
 *     root_candidates
 *     candidate_count
 */
function ace_generate_root_candidates(array $analysis): array
{
    /*
     * Preserve every field produced by earlier pipeline stages.
     */
    $result = $analysis;

    /*
     * Reset only fields owned by this stage.
     */
    $result['root_candidates'] = array();
    $result['candidate_count'] = 0;

    $rootLetters = array();

    /*
     * Prefer the structurally assigned root-letter array.
     */
    if (
        isset($analysis['root_letters'])
        && is_array($analysis['root_letters'])
    ) {
        foreach ($analysis['root_letters'] as $letter) {
            if (!is_string($letter)) {
                continue;
            }

            $letter = trim($letter);

            if ($letter === '') {
                continue;
            }

            if (function_exists('ace_normalize_radical')) {
                $letter = ace_normalize_radical($letter);
            }

            $rootLetters[] = $letter;
        }
    }

    /*
     * A normal root candidate requires exactly three radicals.
     *
     * Do not generate candidates from incomplete roots.
     */
    if (count($rootLetters) !== 3) {
        return $result;
    }

    $candidate = implode('', $rootLetters);

    if ($candidate === '') {
        return $result;
    }

    $result['root_candidates'] = array($candidate);
    $result['candidate_count'] = 1;

    /*
     * Keep the assembled root synchronized.
     */
    $result['root'] = $candidate;
    $result['root_letters'] = $rootLetters;

    return $result;
}
/**
 * ============================================================
 * FUNCTION 14
 * Determine Verb Form
 * ============================================================
 *
 * Preserves all earlier fields and adds:
 *
 *     form
 */
function ace_determine_form(array $analysis): array
{
    $result = $analysis;
    $result['form'] = 'Unknown';

    $toString = static function ($value): string {
        return is_scalar($value)
            ? (string) $value
            : '';
    };

    $r0 = $toString($analysis['r0'] ?? '');
    $r1 = $toString($analysis['r1'] ?? '');
    $r2 = $toString($analysis['r2'] ?? '');

    $vn = $toString($analysis['vn'] ?? '');
    $vm = $toString($analysis['vm'] ?? '');
    $fs = $toString($analysis['fs'] ?? '');
    $v1 = $toString($analysis['v1'] ?? '');

    $r1Repeat = !empty($analysis['r1_repeat']);
    $r2Repeat = !empty($analysis['r2_repeat']);

    /*
     * Support either an explicit boolean field or the structural
     * vowel-slot representation.
     */
    $longV1 = !empty($analysis['long_v1'])
        || $v1 === 'ا';

    /*
     * A form cannot be selected until all three radicals exist.
     */
    if ($r0 === '' || $r1 === '' || $r2 === '') {
        return $result;
    }

    /*
     * Strongest and most specific markers first.
     */

    /*
     * Form X: استفعل
     */
    if ($fs === 'است') {
        $result['form'] = 'X';

        return $result;
    }

    /*
     * Form IX: افعلّ
     *
     * Test before less-specific internal markers.
     */
    if ($r2Repeat) {
        $result['form'] = 'IX';

        return $result;
    }

    /*
     * Form VIII: افتعل
     */
    if ($vm === 'ت') {
        $result['form'] = 'VIII';

        return $result;
    }

    /*
     * Form VII: انفعل
     *
     * Prefer the standardized value ن, but temporarily accept ان.
     */
    if ($fs === 'ن' || $fs === 'ان') {
        $result['form'] = 'VII';

        return $result;
    }

    /*
     * Forms V and VI contain a derivational ت.
     */
    $hasDerivationalT = $vn === 'ت'
        || $fs === 'ت'
        || $vm === 'ت';

    if ($hasDerivationalT && $r1Repeat) {
        $result['form'] = 'V';

        return $result;
    }

    if ($hasDerivationalT && $longV1) {
        $result['form'] = 'VI';

        return $result;
    }

    /*
     * Form IV: أفعل
     *
     * fs should contain أ only when it has already been identified
     * as derivational rather than as first-person imperfect vn.
     */
    if ($fs === 'أ') {
        $result['form'] = 'IV';

        return $result;
    }

    /*
     * Form III: فاعل
     */
    if ($longV1) {
        $result['form'] = 'III';

        return $result;
    }

    /*
     * Form II: فعّل
     */
    if ($r1Repeat) {
        $result['form'] = 'II';

        return $result;
    }

    $result['form'] = 'I';

    return $result;
}
/**
 * ============================================================
 * FUNCTION 15
 * Determine Possible Verb Form(s)
 * ============================================================
 *
 * Preserves all earlier fields and adds:
 *
 *     form_candidates
 *     form_candidate_count
 */
function ace_determine_form_candidates(array $analysis): array
{
    $result = $analysis;

    $result['form_candidates'] = array();
    $result['form_candidate_count'] = 0;

    $toString = static function ($value): string {
        return is_scalar($value)
            ? (string) $value
            : '';
    };

    $containsShadda = static function ($value): bool {
        if (is_array($value)) {
            return in_array("\u{0651}", $value, true);
        }

        if (is_string($value)) {
            return mb_strpos(
                $value,
                "\u{0651}",
                0,
                'UTF-8'
            ) !== false;
        }

        return false;
    };

    $r0 = $toString($analysis['r0'] ?? '');
    $r1 = $toString($analysis['r1'] ?? '');
    $r2 = $toString($analysis['r2'] ?? '');

    $vn = $toString($analysis['vn'] ?? '');
    $vm = $toString($analysis['vm'] ?? '');
    $fs = $toString($analysis['fs'] ?? '');

    $v1 = $toString($analysis['v1'] ?? '');
    $plainStem = $toString($analysis['plain_stem'] ?? '');

    /*
     * Support both explicit repeat flags and shadda marks.
     */
    $r1Repeat = !empty($analysis['r1_repeat'])
        || $containsShadda($analysis['r1_marks'] ?? '');

    $r2Repeat = !empty($analysis['r2_repeat'])
        || $containsShadda($analysis['r2_marks'] ?? '');

    /*
     * A long alif after r0 is the primary marker used for
     * Forms III and VI.
     */
    $longV1 = !empty($analysis['long_v1'])
        || $v1 === 'ا';

    /*
     * Do not propose a form without a complete radical framework.
     */
    if ($r0 === '' || $r1 === '' || $r2 === '') {
        return $result;
    }

    /*
     * Detect the derivational ت used by Forms V and VI.
     */
    $hasInitialDerivationalT = $vn === 'ت'
        || $fs === 'ت';

    /*
     * Imperfect forms can contain a conjugational prefix followed
     * by the derivational ت:
     *
     * يت...
     * تت...
     * أت...
     * نت...
     */
    $firstTwoLetters = mb_substr(
        $plainStem,
        0,
        2,
        'UTF-8'
    );

    if (
        in_array(
            $firstTwoLetters,
            array('يت', 'تت', 'أت', 'ات', 'نت'),
            true
        )
    ) {
        $hasInitialDerivationalT = true;
    }

    /*
     * Specific structural markers take precedence.
     */
    if ($fs === 'است') {
        $result['form_candidates'][] = 'X';
    } elseif ($r2Repeat) {
        $result['form_candidates'][] = 'IX';
    } elseif ($vm === 'ت') {
        $result['form_candidates'][] = 'VIII';
    } elseif ($fs === 'ن' || $fs === 'ان') {
        $result['form_candidates'][] = 'VII';
    } elseif ($r1Repeat && $hasInitialDerivationalT) {
        $result['form_candidates'][] = 'V';
    } elseif ($longV1 && $hasInitialDerivationalT) {
        $result['form_candidates'][] = 'VI';
    } elseif ($fs === 'أ') {
        $result['form_candidates'][] = 'IV';
    } elseif ($r1Repeat) {
        $result['form_candidates'][] = 'II';
    } elseif ($longV1) {
        $result['form_candidates'][] = 'III';
    } else {
        $result['form_candidates'][] = 'I';
    }

    $result['form_candidates'] = array_values(
        array_unique($result['form_candidates'])
    );

    $result['form_candidate_count'] = count(
        $result['form_candidates']
    );

    return $result;
}
/**
 * ============================================================
 * FUNCTION 16
 * Build Root Candidates
 * ============================================================
 *
 * Builds and normalizes the complete root-candidate collection.
 *
 * Preserves all earlier fields and updates:
 *
 *     root
 *     root_candidates
 *     root_candidate_count
 *     candidate_count
 *
 * @param array $analysis
 *
 * @return array
 */
function ace_build_root_candidates(array $analysis)
{
    $result = $analysis;
    $candidates = array();

    $toString = static function ($value) {
        return is_scalar($value)
            ? trim((string) $value)
            : '';
    };

    $normalizeCandidate = static function ($candidate) {
        if (!is_string($candidate)) {
            return '';
        }

        $candidate = trim($candidate);
        $candidate = str_replace('ـ', '', $candidate);

        $normalized = preg_replace(
            '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
            '',
            $candidate
        );

        return is_string($normalized)
            ? $normalized
            : '';
    };

    $r0 = $toString(isset($analysis['r0']) ? $analysis['r0'] : '');
    $r1 = $toString(isset($analysis['r1']) ? $analysis['r1'] : '');
    $r2 = $toString(isset($analysis['r2']) ? $analysis['r2'] : '');

    /*
     * Prefer the direct structurally extracted root.
     */
    if ($r0 !== '' && $r1 !== '' && $r2 !== '') {
        $candidates[] = $r0 . $r1 . $r2;
    }

    /*
     * Include candidates generated by earlier stages.
     */
    if (
        isset($analysis['root_candidates'])
        && is_array($analysis['root_candidates'])
    ) {
        foreach ($analysis['root_candidates'] as $candidate) {
            $candidate = $normalizeCandidate($candidate);

            if ($candidate !== '') {
                $candidates[] = $candidate;
            }
        }
    }

    /*
     * Include roots previously marked as valid, when present.
     *
     * This supports repeated or independently tested pipeline
     * execution, but validation should normally run after this
     * function.
     */
    if (
        isset($analysis['validated_roots'])
        && is_array($analysis['validated_roots'])
    ) {
        foreach ($analysis['validated_roots'] as $validatedRoot) {
            if (!is_array($validatedRoot)) {
                continue;
            }

            if (empty($validatedRoot['valid'])) {
                continue;
            }

            $root = isset($validatedRoot['root'])
                ? $normalizeCandidate($validatedRoot['root'])
                : '';

            if ($root !== '') {
                $candidates[] = $root;
            }
        }
    }

    /*
     * Normalize every candidate before deduplication.
     */
    $normalizedCandidates = array();

    foreach ($candidates as $candidate) {
        $candidate = $normalizeCandidate($candidate);

        if ($candidate !== '') {
            $normalizedCandidates[] = $candidate;
        }
    }

    $candidates = array_values(
        array_unique($normalizedCandidates)
    );

    /*
     * Preserve any previously selected root when it remains a
     * valid member of the candidate collection.
     */
    $existingRoot = isset($analysis['root'])
        ? $normalizeCandidate($analysis['root'])
        : '';

    if (
        $existingRoot !== ''
        && in_array($existingRoot, $candidates, true)
    ) {
        $result['root'] = $existingRoot;
    } else {
        $result['root'] = isset($candidates[0])
            ? $candidates[0]
            : '';
    }

    $result['root_candidates'] = $candidates;
    $result['root_candidate_count'] = count($candidates);

    /*
     * Keep compatibility with Function 13 and existing pages.
     */
    $result['candidate_count'] = count($candidates);

    return $result;
}
/**
 * ============================================================
 * FUNCTION 17
 * Validate Root Candidates
 * ============================================================
 *
 * Validates the complete root-candidate collection built by
 * Function 17.
 *
 * Preserves all earlier fields and adds:
 *
 *     validated_roots
 *     valid_roots
 *     valid_root_count
 *     has_valid_root
 *
 * @param array $analysis
 *
 * @return array
 */
function ace_validate_root_candidates(array $analysis)
{
    $result = $analysis;

    $candidates = array();

    if (
        isset($analysis['root_candidates'])
        && is_array($analysis['root_candidates'])
    ) {
        $candidates = $analysis['root_candidates'];
    }

    $validated = array();
    $validRoots = array();
    $seenRoots = array();

    foreach ($candidates as $candidate) {
        /*
         * A root candidate must be a string.
         */
        if (!is_string($candidate)) {
            $validated[] = array(
                'root'         => '',
                'letters'      => array(),
                'letter_count' => 0,
                'valid'        => false,
                'root_type'    => 'invalid',
                'reason'       => 'Root candidate is not a string',
            );

            continue;
        }

        /*
         * Remove whitespace and tatweel.
         */
        $candidate = trim($candidate);
        $candidate = str_replace('ـ', '', $candidate);

        /*
         * Remove Arabic combining marks.
         */
        $normalizedCandidate = preg_replace(
            '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
            '',
            $candidate
        );

        if (!is_string($normalizedCandidate)) {
            $normalizedCandidate = '';
        }

        $candidate = $normalizedCandidate;

        /*
         * Reject empty candidates.
         */
        if ($candidate === '') {
            $validated[] = array(
                'root'         => '',
                'letters'      => array(),
                'letter_count' => 0,
                'valid'        => false,
                'root_type'    => 'invalid',
                'reason'       => 'Empty root candidate',
            );

            continue;
        }

        /*
         * Avoid validating the same normalized root twice.
         */
        if (isset($seenRoots[$candidate])) {
            continue;
        }

        $seenRoots[$candidate] = true;

        $letters = preg_split(
            '//u',
            $candidate,
            -1,
            PREG_SPLIT_NO_EMPTY
        );

        if (!is_array($letters)) {
            $letters = array();
        }

        $letterCount = count($letters);
        $valid = false;
        $rootType = 'invalid';
        $reason = 'Root candidate must contain three or four letters';

        if ($letterCount === 3) {
            $valid = true;
            $rootType = 'trilateral';
            $reason = 'Valid triliteral root candidate';
        } elseif ($letterCount === 4) {
            $valid = true;
            $rootType = 'quadriliteral';
            $reason = 'Valid quadriliteral root candidate';
        }

        $validated[] = array(
            'root'         => $candidate,
            'letters'      => $letters,
            'letter_count' => $letterCount,
            'valid'        => $valid,
            'root_type'    => $rootType,
            'reason'       => $reason,
        );

        if ($valid) {
            $validRoots[] = $candidate;
        }
    }

    $result['validated_roots'] = $validated;
    $result['valid_roots'] = array_values(array_unique($validRoots));
    $result['valid_root_count'] = count($result['valid_roots']);
    $result['has_valid_root'] = $result['valid_root_count'] > 0;

    return $result;
}
/**
 * ============================================================
 * FUNCTION 18
 * Select Final Root
 * ============================================================
 *
 * Chooses the best root candidate from the generated list.
 *
 * Current strategy:
 *   1. No candidates → empty root.
 *   2. One candidate → select it.
 *   3. Multiple candidates → select the first.
 *
 * Later versions may replace this logic with:
 *   - dictionary lookup
 *   - verb-form compatibility
 *   - weak-root reconstruction
 *   - frequency scoring
 */

function ace_select_final_root(array $analysis): array
{
    $candidates = $analysis['root_candidates'] ?? [];

    $analysis['final_root'] = '';

    if (empty($candidates)) {
        return $analysis;
    }

    /*
     * Single candidate.
     */
    if (count($candidates) === 1) {
        $analysis['final_root'] = $candidates[0];
        return $analysis;
    }

    /*
     * Placeholder for future ranking logic.
     *
     * Future:
     *  - dictionary validation
     *  - weak-root recovery
     *  - form compatibility
     *  - confidence scoring
     */

    $analysis['final_root'] = reset($candidates);

    return $analysis;
}

/**
 * ============================================================
 * FUNCTION 19
 * Identify Form Candidates
 * ============================================================
 *
 * Uses the extracted structural markers to identify all
 * compatible Arabic derived verb forms.
 *
 * Important:
 * - This function does not remove prefixes.
 * - It only interprets markers already extracted.
 * - More than one candidate may be returned when the
 *   available structure is ambiguous.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<int, string>
 */
function ace_identify_form_candidates(array $analysis): array
{
    $forms = [];

    $vn = (string) ($analysis['vn'] ?? '');
    $vm = (string) ($analysis['vm'] ?? '');
    $fs = (string) ($analysis['fs'] ?? '');
    $v1 = (string) ($analysis['v1'] ?? '');

    $r1Repeat = !empty($analysis['r1_repeat']);
    $r2Repeat = !empty($analysis['r2_repeat']);

    /*
     * Form X
     *
     * Perfect:
     * اِسْتَفْعَلَ
     *
     * Imperfect:
     * يَسْتَفْعِلُ
     *
     * Depending on Function 10 extraction, fs may contain
     * either است or ست.
     */
    if (in_array($fs, ['است', 'ست'], true)) {
        $forms[] = 'X';
    }

    /*
     * Form IX
     *
     * اِفْعَلَّ
     *
     * The third radical is doubled.
     */
    if ($r2Repeat) {
        $forms[] = 'IX';
    }

    /*
     * Form VIII
     *
     * اِفْتَعَلَ
     *
     * The internal derivational marker is ت.
     */
    if ($vm === 'ت') {
        $forms[] = 'VIII';
    }

    /*
     * Form VII
     *
     * اِنْفَعَلَ
     * يَنْفَعِلُ
     *
     * Depending on extraction, fs may contain ان or ن.
     */
    if (in_array($fs, ['ان', 'ن'], true)) {
        $forms[] = 'VII';
    }

    /*
     * Form VI
     *
     * تَفَاعَلَ
     *
     * Initial derivational ت plus a long ا after r0.
     */
    if ($vn === 'ت' && $v1 === 'ا') {
        $forms[] = 'VI';
    }

    /*
     * Form V
     *
     * تَفَعَّلَ
     *
     * Initial derivational ت plus doubled r1.
     */
    if ($vn === 'ت' && $r1Repeat) {
        $forms[] = 'V';
    }

    /*
     * Form IV
     *
     * أَفْعَلَ
     *
     * Initial derivational hamzah.
     */
    if (in_array($vn, ['أ', 'إ'], true)) {
        $forms[] = 'IV';
    }

    /*
     * Form III
     *
     * فَاعَلَ
     *
     * A long ا follows r0, provided the initial ت has not
     * already identified the structure as Form VI.
     */
    if ($v1 === 'ا' && $vn !== 'ت') {
        $forms[] = 'III';
    }

    /*
     * Form II
     *
     * فَعَّلَ
     *
     * The second radical is doubled, provided an initial ت
     * has not already identified the structure as Form V.
     */
    if ($r1Repeat && $vn !== 'ت') {
        $forms[] = 'II';
    }

    /*
     * Form I
     *
     * Used only when no derived-form marker was detected.
     */
    if (empty($forms)) {
        $forms[] = 'I';
    }

    return array_values(array_unique($forms));
}

/**
 * ============================================================
 * FUNCTION 20
 * Select Best Root Candidate
 * ============================================================
 *
 * Current selection rules:
 * 1. Prefer the first valid triliteral candidate.
 * 2. Ignore empty or non-string candidates.
 * 3. Fall back to the first usable candidate.
 *
 * Later, candidates can be scored using:
 * - detected verb form
 * - weak-root compatibility
 * - dictionary validation
 * - structural confidence
 */
function ace_select_best_root_candidate(array $analysis): array
{
    $candidates = $analysis['root_candidates'] ?? [];

    /*
     * Always initialize output fields.
     */
    $analysis['selected_root'] = '';
    $analysis['selection_reason'] = 'No usable candidates';

    if (!is_array($candidates) || empty($candidates)) {
        $analysis['selection_reason'] = 'No candidates';
        return $analysis;
    }

    $usableCandidates = [];

    foreach ($candidates as $candidate) {

        if (!is_string($candidate)) {
            continue;
        }

        $candidate = trim($candidate);

        if ($candidate === '') {
            continue;
        }

        /*
         * Root candidates should normally already be plain,
         * but remove Arabic marks before measuring length.
         */
        $plainCandidate = preg_replace(
            '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}\x{0640}]/u',
            '',
            $candidate
        );

        if (!is_string($plainCandidate) || $plainCandidate === '') {
            continue;
        }

        $usableCandidates[] = $plainCandidate;

        /*
         * Prefer the first triliteral candidate.
         */
        if (mb_strlen($plainCandidate, 'UTF-8') === 3) {
            $analysis['selected_root'] = $plainCandidate;
            $analysis['selection_reason'] =
                'First valid triliteral candidate';

            return $analysis;
        }
    }

    /*
     * Fall back to the first usable non-triliteral candidate.
     */
    if (!empty($usableCandidates)) {
        $analysis['selected_root'] = $usableCandidates[0];
        $analysis['selection_reason'] =
            'No triliteral candidate; selected first usable candidate';
    }

    return $analysis;
}

/**
 * ============================================================
 * FUNCTION 21
 * Detect Weak Radicals
 * ============================================================
 *
 * Detects weak radicals in the selected triliteral root.
 *
 * Root-source priority:
 *   1. selected_root
 *   2. final_root
 *   3. root
 *   4. extracted r0/r1/r2 slots
 *
 * Weak radical letters:
 *   - و = waw
 *   - ي = ya
 *   - ا = unresolved surface alif
 *   - ى = unresolved surface alif maqsura
 *
 * Hamza is not treated as a weak radical.
 *
 * Adds:
 *   - weak_analysis_root
 *   - weak_analysis_source
 *   - weak_r0
 *   - weak_r1
 *   - weak_r2
 *   - r0_is_weak
 *   - r1_is_weak
 *   - r2_is_weak
 *   - has_weak_radical
 *   - weak_radical_count
 *   - weak_radical_positions
 *   - weak_pattern
 *   - weak_class
 *   - confirmed_underlying_weak_count
 *   - has_unresolved_surface_weak_letter
 *
 * @param array $analysis
 *
 * @return array
 */
function ace_detect_weak_radicals(array $analysis): array
{
    /*
     * Preserve every field produced by preceding stages.
     */
    $result = $analysis;

    /*
     * Find the best available root.
     */
    $root = '';
    $rootSource = 'radical_slots';

    foreach (
        array(
            'selected_root',
            'final_root',
            'root',
        ) as $rootField
    ) {
        if (
            isset($analysis[$rootField])
            && is_string($analysis[$rootField])
            && trim($analysis[$rootField]) !== ''
        ) {
            $root = trim($analysis[$rootField]);
            $rootSource = $rootField;
            break;
        }
    }

    /*
     * Begin with the extracted radical slots.
     */
    $r0 = (
        isset($analysis['r0'])
        && is_scalar($analysis['r0'])
    )
        ? trim((string) $analysis['r0'])
        : '';

    $r1 = (
        isset($analysis['r1'])
        && is_scalar($analysis['r1'])
    )
        ? trim((string) $analysis['r1'])
        : '';

    $r2 = (
        isset($analysis['r2'])
        && is_scalar($analysis['r2'])
    )
        ? trim((string) $analysis['r2'])
        : '';

    /*
     * Use a selected root only when it is triliteral.
     *
     * Quadriliteral roots must not be silently truncated.
     */
    if ($root !== '') {
        $rootLetters = preg_split(
            '//u',
            $root,
            -1,
            PREG_SPLIT_NO_EMPTY
        );

        if (
            is_array($rootLetters)
            && count($rootLetters) === 3
        ) {
            $r0 = $rootLetters[0];
            $r1 = $rootLetters[1];
            $r2 = $rootLetters[2];
        } else {
            /*
             * Fall back to the extracted slots when the selected
             * root is not triliteral.
             */
            $rootSource = 'radical_slots';
        }
    }

    /*
     * Build the effective root used by this function.
     */
    $effectiveRoot = $r0 . $r1 . $r2;

    /*
     * Confirmed underlying weak radicals.
     */
    $underlyingWeakLetters = array(
        'و',
        'ي',
    );

    /*
     * Surface weak letters.
     *
     * Alif and alif maqsura are treated as unresolved surface
     * weak letters until a later reconstruction stage determines
     * whether the underlying radical is waw or ya.
     */
    $surfaceWeakLetters = array(
        'و',
        'ي',
        'ا',
        'ى',
    );

    $r0IsWeak = (
        $r0 !== ''
        && in_array($r0, $surfaceWeakLetters, true)
    );

    $r1IsWeak = (
        $r1 !== ''
        && in_array($r1, $surfaceWeakLetters, true)
    );

    $r2IsWeak = (
        $r2 !== ''
        && in_array($r2, $surfaceWeakLetters, true)
    );

    /*
     * Collect weak-radical positions.
     */
    $weakPositions = array();

    if ($r0IsWeak) {
        $weakPositions[] = 'r0';
    }

    if ($r1IsWeak) {
        $weakPositions[] = 'r1';
    }

    if ($r2IsWeak) {
        $weakPositions[] = 'r2';
    }

    $weakCount = count($weakPositions);

    /*
     * Broad positional pattern.
     */
    if ($weakCount === 0) {
        $weakPattern = 'sound';
    } elseif ($weakCount === 1) {
        if ($r0IsWeak) {
            $weakPattern = 'initial_weak';
        } elseif ($r1IsWeak) {
            $weakPattern = 'middle_weak';
        } else {
            $weakPattern = 'final_weak';
        }
    } elseif ($weakCount === 2) {
        if ($r0IsWeak && $r1IsWeak) {
            $weakPattern = 'initial_middle_weak';
        } elseif ($r1IsWeak && $r2IsWeak) {
            $weakPattern = 'middle_final_weak';
        } else {
            $weakPattern = 'initial_final_weak';
        }
    } else {
        $weakPattern = 'triple_weak';
    }

    /*
     * Traditional Arabic weak-root classification.
     */
    if ($weakCount === 0) {
        $weakClass = 'sound';
    } elseif ($weakCount === 1 && $r0IsWeak) {
        $weakClass = 'mithal';
    } elseif ($weakCount === 1 && $r1IsWeak) {
        $weakClass = 'ajwaf';
    } elseif ($weakCount === 1 && $r2IsWeak) {
        $weakClass = 'naqis';
    } elseif (
        $weakCount === 2
        && $r0IsWeak
        && $r2IsWeak
    ) {
        $weakClass = 'lafif_mafruq';
    } elseif (
        $weakCount === 2
        && (
            ($r0IsWeak && $r1IsWeak)
            || ($r1IsWeak && $r2IsWeak)
        )
    ) {
        $weakClass = 'lafif_maqrun';
    } elseif ($weakCount === 3) {
        $weakClass = 'triple_weak';
    } else {
        $weakClass = 'mixed_weak';
    }

    /*
     * Count confirmed waw/ya radicals and identify unresolved
     * surface alif forms.
     */
    $confirmedUnderlyingCount = 0;
    $hasSurfaceAlif = false;

    foreach (array($r0, $r1, $r2) as $radical) {
        if (
            in_array(
                $radical,
                $underlyingWeakLetters,
                true
            )
        ) {
            $confirmedUnderlyingCount++;
        }

        if (
            $radical === 'ا'
            || $radical === 'ى'
        ) {
            $hasSurfaceAlif = true;
        }
    }

    /*
     * Add fields owned by this stage.
     */
    $result['weak_analysis_root'] = $effectiveRoot;
    $result['weak_analysis_source'] = $rootSource;

    $result['weak_r0'] = $r0;
    $result['weak_r1'] = $r1;
    $result['weak_r2'] = $r2;

    $result['r0_is_weak'] = $r0IsWeak;
    $result['r1_is_weak'] = $r1IsWeak;
    $result['r2_is_weak'] = $r2IsWeak;

    $result['has_weak_radical'] = $weakCount > 0;
    $result['weak_radical_count'] = $weakCount;
    $result['weak_radical_positions'] = $weakPositions;

    $result['weak_pattern'] = $weakPattern;
    $result['weak_class'] = $weakClass;

    $result['confirmed_underlying_weak_count']
        = $confirmedUnderlyingCount;

    $result['has_unresolved_surface_weak_letter']
        = $hasSurfaceAlif;

    return $result;
}
//------------------------
/**
 * ============================================================
 * FUNCTION 22
 * Resolve Mithal Roots
 * ============================================================
 *
 * Placeholder for roots whose first radical is weak:
 *
 *     و-ع-د
 *     ي-ق-ن
 *
 * Later this function will reconstruct omitted or transformed
 * initial weak radicals.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_resolve_mithal_roots')) {
    function ace_resolve_mithal_roots(array $analysis): array
    {
        $result = $analysis;

        $result['mithal_resolution_applied'] = false;
        $result['mithal_candidates'] = array();
        $result['mithal_resolution_status'] = 'not_implemented';

        return $result;
    }
}


/**
 * ============================================================
 * FUNCTION 23
 * Resolve Hollow Roots
 * ============================================================
 *
 * Placeholder for roots whose second radical is weak:
 *
 *     ق-و-ل
 *     ب-ي-ع
 *
 * Later this function will reconstruct the underlying wāw or
 * yāʾ from surface alif, vowel patterns, form, voice, and tense.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_resolve_hollow_roots')) {
    function ace_resolve_hollow_roots(array $analysis): array
    {
        $result = $analysis;

        $result['hollow_resolution_applied'] = false;
        $result['hollow_candidates'] = array();
        $result['hollow_resolution_status'] = 'not_implemented';

        return $result;
    }
}


/**
 * ============================================================
 * FUNCTION 24
 * Resolve Defective Roots
 * ============================================================
 *
 * Placeholder for roots whose final radical is weak:
 *
 *     د-ع-و
 *     ر-م-ي
 *
 * Later this function will handle:
 *
 * - retained final و or ي
 * - final ا or ى
 * - deleted final weak radicals
 * - suffix absorption
 * - ambiguous final و/ي reconstruction
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_resolve_defective_roots')) {
    function ace_resolve_defective_roots(array $analysis): array
    {
        $result = $analysis;

        $result['defective_resolution_applied'] = false;
        $result['defective_candidates'] = array();
        $result['defective_resolution_status'] = 'not_implemented';

        return $result;
    }
}


/**
 * ============================================================
 * FUNCTION 25
 * Resolve Hamzated Roots
 * ============================================================
 *
 * Placeholder for roots containing hamza in any radical slot:
 *
 *     أ-ك-ل
 *     س-أ-ل
 *     ق-ر-أ
 *
 * Hamza is not a weak radical, but its surface seat may vary:
 *
 *     أ  إ  ؤ  ئ  ء
 *
 * Later this function will normalize surface hamza forms to the
 * underlying lexical radical ء.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_resolve_hamzated_roots')) {
    function ace_resolve_hamzated_roots(array $analysis): array
    {
        $result = $analysis;

        $result['hamzated_resolution_applied'] = false;
        $result['hamzated_candidates'] = array();
        $result['hamzated_resolution_status'] = 'not_implemented';

        return $result;
    }
}


/**
 * ============================================================
 * FUNCTION 26
 * Merge Weak Root Candidates
 * ============================================================
 *
 * Combines root candidates generated by:
 *
 * - structural root extraction
 * - mithal reconstruction
 * - hollow reconstruction
 * - defective reconstruction
 * - hamzated normalization
 *
 * It also performs limited fallback reconstruction when the
 * structural analysis contains only two radicals.
 *
 * Important rules:
 *
 * 1. Hamza forms أ إ ؤ ئ are normalized to ء.
 * 2. Final alif maqsurah ى is normalized to ي.
 * 3. A missing final radical generates both و and ي candidates.
 * 4. A missing initial radical generates both و and ي candidates.
 * 5. Existing candidates are preserved and deduplicated.
 * 6. Candidate source and evidence are retained separately.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_merge_weak_root_candidates')) {
    function ace_merge_weak_root_candidates(array $analysis): array
    {
        $result = $analysis;

        /*
         * --------------------------------------------------------
         * Internal helpers.
         * --------------------------------------------------------
         */

        /**
         * Normalize one Arabic radical.
         */
        $normalizeRadical = static function ($radical): string {
            if (!is_string($radical)) {
                return '';
            }

            $radical = trim($radical);

            if ($radical === '') {
                return '';
            }

            /*
             * Remove Arabic combining marks.
             */
            $radical = preg_replace(
                '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
                '',
                $radical
            );

            if (!is_string($radical)) {
                return '';
            }

            /*
             * Normalize all written hamza forms.
             */
            $radical = str_replace(
                array('أ', 'إ', 'ؤ', 'ئ'),
                'ء',
                $radical
            );

            /*
             * Alif maqsurah normally represents final yāʾ at the
             * lexical-root level.
             */
            if ($radical === 'ى') {
                return 'ي';
            }

            return $radical;
        };

        /**
         * Normalize a complete root candidate.
         */
        $normalizeRoot = static function ($root) use (
            $normalizeRadical
        ): string {
            if (!is_string($root)) {
                return '';
            }

            $root = trim($root);

            if ($root === '') {
                return '';
            }

            /*
             * Remove separators used by display-oriented roots.
             */
            $root = preg_replace(
                '/[\s\-\x{2010}-\x{2015},،\/\\\\|]+/u',
                '',
                $root
            );

            if (!is_string($root) || $root === '') {
                return '';
            }

            /*
             * Remove Arabic marks before splitting letters.
             */
            $root = preg_replace(
                '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
                '',
                $root
            );

            if (!is_string($root) || $root === '') {
                return '';
            }

            $letters = preg_split(
                '//u',
                $root,
                -1,
                PREG_SPLIT_NO_EMPTY
            );

            if (!is_array($letters)) {
                return '';
            }

            $normalizedLetters = array();

            foreach ($letters as $letter) {
                $normalizedLetter = $normalizeRadical($letter);

                if ($normalizedLetter !== '') {
                    $normalizedLetters[] = $normalizedLetter;
                }
            }

            return implode('', $normalizedLetters);
        };

        /**
         * Return the Unicode length of a root.
         */
        $rootLength = static function (string $root): int {
            if (function_exists('mb_strlen')) {
                return mb_strlen($root, 'UTF-8');
            }

            $letters = preg_split(
                '//u',
                $root,
                -1,
                PREG_SPLIT_NO_EMPTY
            );

            return is_array($letters)
                ? count($letters)
                : 0;
        };

        /*
         * Candidate map:
         *
         * normalized root => supporting sources and evidence
         */
        $candidateMap = array();

        /**
         * Add one candidate with provenance.
         */
        $addCandidate = static function (
            $candidate,
            string $source,
            string $evidence = ''
        ) use (
            &$candidateMap,
            $normalizeRoot,
            $rootLength
        ): void {
            $normalized = $normalizeRoot($candidate);

            if ($normalized === '') {
                return;
            }

            $length = $rootLength($normalized);

            /*
             * Weak-root processing currently accepts triliteral
             * and quadriliteral candidates only.
             *
             * Two-radical structures are handled later by the
             * reconstruction fallback.
             */
            if ($length !== 3 && $length !== 4) {
                return;
            }

            if (!isset($candidateMap[$normalized])) {
                $candidateMap[$normalized] = array(
                    'root'     => $normalized,
                    'sources'  => array(),
                    'evidence' => array(),
                );
            }

            if ($source !== '') {
                $candidateMap[$normalized]['sources'][] = $source;
            }

            if ($evidence !== '') {
                $candidateMap[$normalized]['evidence'][] = $evidence;
            }
        };

        /**
         * Add candidates from a field that may contain:
         *
         * - one root string
         * - a list of root strings
         * - candidate records containing root/candidate fields
         */
        $collectCandidateField = static function (
            $value,
            string $source
        ) use (
            &$addCandidate
        ): void {
            if (is_string($value)) {
                $addCandidate(
                    $value,
                    $source,
                    'Existing root candidate'
                );

                return;
            }

            if (!is_array($value)) {
                return;
            }

            foreach ($value as $candidate) {
                if (is_string($candidate)) {
                    $addCandidate(
                        $candidate,
                        $source,
                        'Existing root candidate'
                    );

                    continue;
                }

                if (!is_array($candidate)) {
                    continue;
                }

                $candidateRoot = '';

                foreach (
                    array(
                        'root',
                        'candidate',
                        'normalized_root',
                        'selected_root',
                        'final_root',
                    ) as $rootKey
                ) {
                    if (
                        isset($candidate[$rootKey])
                        && is_string($candidate[$rootKey])
                        && trim($candidate[$rootKey]) !== ''
                    ) {
                        $candidateRoot = $candidate[$rootKey];
                        break;
                    }
                }

                if ($candidateRoot === '') {
                    continue;
                }

                $candidateEvidence = '';

                if (
                    isset($candidate['evidence'])
                    && is_string($candidate['evidence'])
                ) {
                    $candidateEvidence = $candidate['evidence'];
                }

                $addCandidate(
                    $candidateRoot,
                    $source,
                    $candidateEvidence !== ''
                        ? $candidateEvidence
                        : 'Existing candidate record'
                );
            }
        };

        /*
         * --------------------------------------------------------
         * 1. Collect complete roots from individual fields.
         * --------------------------------------------------------
         */
        $singleRootFields = array(
            'selected_root',
            'final_root',
            'root',
            'structural_root',
            'preliminary_root',
            'normalized_root',
            'mithal_root',
            'hollow_root',
            'defective_root',
            'hamzated_root',
        );

        foreach ($singleRootFields as $field) {
            if (isset($analysis[$field])) {
                $collectCandidateField(
                    $analysis[$field],
                    $field
                );
            }
        }

        /*
         * --------------------------------------------------------
         * 2. Collect candidates produced by earlier stages.
         * --------------------------------------------------------
         */
        $candidateFields = array(
            'root_candidates',
            'generated_root_candidates',
            'validated_roots',
            'valid_roots',
            'structural_root_candidates',
            'mithal_root_candidates',
            'hollow_root_candidates',
            'defective_root_candidates',
            'hamzated_root_candidates',
            'weak_initial_candidates',
            'weak_medial_candidates',
            'weak_final_candidates',
        );

        foreach ($candidateFields as $field) {
            if (isset($analysis[$field])) {
                $collectCandidateField(
                    $analysis[$field],
                    $field
                );
            }
        }

        /*
         * --------------------------------------------------------
         * 3. Read normalized structural slots.
         * --------------------------------------------------------
         */
        $r0 = isset($analysis['r0'])
            ? $normalizeRadical($analysis['r0'])
            : '';

        $r1 = isset($analysis['r1'])
            ? $normalizeRadical($analysis['r1'])
            : '';

        $r2 = isset($analysis['r2'])
            ? $normalizeRadical($analysis['r2'])
            : '';

        /*
         * A complete structural root should always be retained.
         */
        if ($r0 !== '' && $r1 !== '' && $r2 !== '') {
            $addCandidate(
                $r0 . $r1 . $r2,
                'structural_slots',
                'Complete r0, r1, and r2 assignment'
            );
        }

        /*
         * --------------------------------------------------------
         * 4. Reconstruct a neutralized final weak radical.
         * --------------------------------------------------------
         *
         * Examples:
         *
         *     دَعَا       structural radicals: د + ع
         *     يَدْعُونَ   structural radicals: د + ع
         *
         * Surface morphology does not independently establish
         * whether the missing final radical is و or ي.
         *
         * Therefore:
         *
         *     دعو
         *     دعي
         */
        if ($r0 !== '' && $r1 !== '' && $r2 === '') {
            $addCandidate(
                $r0 . $r1 . 'و',
                'defective_fallback',
                'Missing r2 reconstructed as و'
            );

            $addCandidate(
                $r0 . $r1 . 'ي',
                'defective_fallback',
                'Missing r2 reconstructed as ي'
            );
        }

        /*
         * --------------------------------------------------------
         * 5. Reconstruct a missing initial weak radical.
         * --------------------------------------------------------
         *
         * This is useful when an imperfect form loses initial wāw
         * after the person marker.
         *
         * Both alternatives are retained until lexical or
         * conjugational validation resolves them.
         */
        if ($r0 === '' && $r1 !== '' && $r2 !== '') {
            $addCandidate(
                'و' . $r1 . $r2,
                'mithal_fallback',
                'Missing r0 reconstructed as و'
            );

            $addCandidate(
                'ي' . $r1 . $r2,
                'mithal_fallback',
                'Missing r0 reconstructed as ي'
            );
        }

        /*
         * --------------------------------------------------------
         * 6. Handle roots written with final long alif.
         * --------------------------------------------------------
         *
         * If a previously selected root still contains final ا,
         * retain both possible lexical weak-radical restorations.
         *
         * Examples:
         *
         *     دعا -> دعو / دعي
         *     حيا -> حيو / حيي
         */
        $longAlifSources = array(
            'selected_root',
            'final_root',
            'root',
            'structural_root',
        );

        foreach ($longAlifSources as $field) {
            if (
                !isset($analysis[$field])
                || !is_string($analysis[$field])
            ) {
                continue;
            }

            $rawRoot = trim($analysis[$field]);

            if ($rawRoot === '') {
                continue;
            }

            $rawRoot = preg_replace(
                '/[\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
                '',
                $rawRoot
            );

            if (!is_string($rawRoot) || $rawRoot === '') {
                continue;
            }

            if (preg_match('/ا$/u', $rawRoot) === 1) {
                $base = preg_replace('/ا$/u', '', $rawRoot);

                if (is_string($base) && $base !== '') {
                    $addCandidate(
                        $base . 'و',
                        'final_alif_fallback',
                        'Final ا reconstructed as و'
                    );

                    $addCandidate(
                        $base . 'ي',
                        'final_alif_fallback',
                        'Final ا reconstructed as ي'
                    );
                }
            }
        }

        /*
         * --------------------------------------------------------
         * 7. Finalize candidate details.
         * --------------------------------------------------------
         */
        foreach ($candidateMap as &$candidateRecord) {
            $candidateRecord['sources'] = array_values(
                array_unique($candidateRecord['sources'])
            );

            $candidateRecord['evidence'] = array_values(
                array_unique($candidateRecord['evidence'])
            );

            $candidateRecord['source_count'] = count(
                $candidateRecord['sources']
            );
        }

        unset($candidateRecord);

        $candidateDetails = array_values($candidateMap);

        /*
         * Preserve the simple string-list field expected by the
         * existing display and validation pipeline.
         */
        $mergedCandidates = array_keys($candidateMap);

        $result['weak_root_candidates'] = $mergedCandidates;

        $result['weak_root_candidate_details'] = $candidateDetails;

        $result['weak_root_candidate_count'] = count(
            $mergedCandidates
        );

        /*
         * --------------------------------------------------------
         * 8. Report merge status.
         * --------------------------------------------------------
         */
        if (count($mergedCandidates) === 0) {
            $result['weak_root_merge_status'] = 'no_candidates';
        } elseif (count($mergedCandidates) === 1) {
            $result['weak_root_merge_status'] = 'single_candidate';
        } else {
            $result['weak_root_merge_status'] = 'multiple_candidates';
        }

        $result['weak_root_merge_complete'] = true;

        return $result;
    }
}

/**
 * ============================================================
 * FUNCTION 27
 * Score Weak Root Candidates
 * ============================================================
 *
 * Placeholder for scoring lexical root candidates.
 *
 * Later scoring may use:
 *
 * - dictionary validation
 * - verb-form compatibility
 * - surface weak-letter evidence
 * - vowel-pattern evidence
 * - suffix interaction
 * - token family
 * - frequency
 * - ambiguity penalties
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_score_weak_root_candidates')) {
    function ace_score_weak_root_candidates(array $analysis): array
    {
        $result = $analysis;

        $candidates =
            isset($analysis['weak_root_candidates'])
            && is_array($analysis['weak_root_candidates'])
                ? $analysis['weak_root_candidates']
                : array();

        $scoredCandidates = array();

        foreach ($candidates as $index => $candidate) {
            if (!is_string($candidate)) {
                continue;
            }

            $candidate = trim($candidate);

            if ($candidate === '') {
                continue;
            }

            /*
             * Temporary neutral score.
             *
             * The first structural candidate receives no linguistic
             * preference. The index is retained only for stable
             * ordering.
             */
            $scoredCandidates[] = array(
                'root' => $candidate,
                'score' => 0,
                'rank_order' => $index,
                'source' => 'placeholder',
                'reasons' => array(
                    'Weak-root scoring is not implemented yet',
                ),
            );
        }

        $result['scored_weak_root_candidates'] =
            $scoredCandidates;

        $result['weak_root_scoring_status'] = 'not_implemented';

        return $result;
    }
}


/**
 * ============================================================
 * FUNCTION 28
 * Select Final Lexical Root
 * ============================================================
 *
 * Selects the final lexical root after all weak-root resolution
 * and scoring stages.
 *
 * Until scoring is implemented, this placeholder uses:
 *
 * 1. First scored weak-root candidate.
 * 2. First merged weak-root candidate.
 * 3. selected_root.
 * 4. final_root.
 *
 * @param array<string, mixed> $analysis
 *
 * @return array<string, mixed>
 */
if (!function_exists('ace_select_final_lexical_root')) {
    function ace_select_final_lexical_root(
        array $analysis
    ): array {
        $result = $analysis;

        $finalLexicalRoot = '';
        $selectionSource = '';

        /*
         * First preference:
         * first scored weak-root candidate.
         */
        if (
            isset($analysis['scored_weak_root_candidates'])
            && is_array($analysis['scored_weak_root_candidates'])
            && !empty($analysis['scored_weak_root_candidates'])
        ) {
            $firstScored =
                $analysis['scored_weak_root_candidates'][0];

            if (
                is_array($firstScored)
                && isset($firstScored['root'])
                && is_string($firstScored['root'])
            ) {
                $finalLexicalRoot = trim(
                    $firstScored['root']
                );

                if ($finalLexicalRoot !== '') {
                    $selectionSource =
                        'scored_weak_root_candidates';
                }
            }
        }

        /*
         * Second preference:
         * first merged weak-root candidate.
         */
        if (
            $finalLexicalRoot === ''
            && isset($analysis['weak_root_candidates'])
            && is_array($analysis['weak_root_candidates'])
            && !empty($analysis['weak_root_candidates'])
        ) {
            $firstWeakCandidate =
                $analysis['weak_root_candidates'][0];

            if (is_string($firstWeakCandidate)) {
                $finalLexicalRoot = trim(
                    $firstWeakCandidate
                );

                if ($finalLexicalRoot !== '') {
                    $selectionSource =
                        'weak_root_candidates';
                }
            }
        }

        /*
         * Third preference:
         * best structural root from Function 20.
         */
        if (
            $finalLexicalRoot === ''
            && isset($analysis['selected_root'])
            && is_string($analysis['selected_root'])
        ) {
            $finalLexicalRoot = trim(
                $analysis['selected_root']
            );

            if ($finalLexicalRoot !== '') {
                $selectionSource = 'selected_root';
            }
        }

        /*
         * Final fallback:
         * preliminary root from Function 18.
         */
        if (
            $finalLexicalRoot === ''
            && isset($analysis['final_root'])
            && is_string($analysis['final_root'])
        ) {
            $finalLexicalRoot = trim(
                $analysis['final_root']
            );

            if ($finalLexicalRoot !== '') {
                $selectionSource = 'final_root';
            }
        }

        $result['final_lexical_root'] =
            $finalLexicalRoot;

        $result['final_lexical_root_source'] =
            $selectionSource;

        $result['final_lexical_root_selected'] =
            $finalLexicalRoot !== '';

        $result['lexical_root_selection_status'] =
            $finalLexicalRoot !== ''
                ? 'placeholder_selection'
                : 'no_candidate';

        return $result;
    }
}
//------------------------
function ace_chars(string $text): array
{
    if ($text === '') {
        return [];
    }

    $characters = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);

    return is_array($characters) ? $characters : [];
}

/**
 * ============================================================
 * FUNCTION 3
 * Remove Arabic diacritics
 * ============================================================
 *
 * Removes Arabic combining marks and tatweel while preserving
 * every written base letter, including:
 *
 *     ا  و  ي  ى
 *     أ  إ  آ  ؤ  ئ  ء
 *
 * Important:
 *
 * - Shadda is removed here, not expanded.
 * - Shadda expansion belongs to the structural-skeleton stage.
 * - Long-vowel letters are preserved.
 * - Weak radicals are not reconstructed here.
 * - Hamza forms are not normalized here.
 * - Suffix-analysis decisions are not corrected here.
 *
 * Examples:
 *
 *     تَدَرَّس  -> تدرس
 *     يَتَدَرَّس -> يتدرس
 *     اِحْمَرّ  -> احمر
 *     قَال      -> قال
 *     يَقُول    -> يقول
 *     قَرَأ     -> قرأ
 *     وَقَى     -> وقى
 *
 * @param string $text Marked Arabic text.
 *
 * @return string Text containing base letters only.
 */
function ace_remove_diacritics(string $text): string
{
    if ($text === '') {
        return '';
    }

    /*
     * Remove the main Arabic combining-mark ranges:
     *
     * U+0610–U+061A  Arabic signs
     * U+064B–U+065F  tanwin, vowels, shadda, sukun, etc.
     * U+0670         dagger alif
     * U+06D6–U+06ED  Qur'anic annotation marks
     *
     * Remove U+0640 tatweel separately because it is not a
     * combining mark.
     */
    $plain = preg_replace(
        '/[\x{0610}-\x{061A}'
        . '\x{064B}-\x{065F}'
        . '\x{0670}'
        . '\x{06D6}-\x{06ED}'
        . '\x{0640}]/u',
        '',
        $text
    );

    /*
     * preg_replace() may return null if malformed UTF-8 reaches
     * the function. Preserve the original input rather than
     * causing a return-type error.
     */
    return is_string($plain)
        ? $plain
        : $text;
}
function ace_empty_dissection(): array
{
    return [
        'vn' => '',
        'vm' => '',

        'r0' => '',
        'v1' => '',

        'r1' => '',
        'r1_repeat' => false,

        'v2' => '',

        'r2' => '',
        'r2_repeat' => false,

        'remaining' => '',
    ];
}
/**
 * Build a letter skeleton from a marked Arabic stem.
 *
 * Arabic combining marks and tatweel are removed. A shadda is
 * represented structurally by repeating the consonant that
 * carries it.
 *
 * Examples:
 *
 *     تَدَرَّس   → تدررس
 *     يَتَدَرَّس → يتدررس
 *     اِحْمَرّ   → احمرر
 *     اِحْمَرَر  → احمرر
 *
 * Long-vowel and weak letters such as ا, و, ي, and ى are
 * preserved because they are base letters, not diacritics.
 */
function ace_build_expanded_skeleton(string $text): string
{
    $result       = '';
    $previousBase = '';

    $ignoredMarks = [
        ACE_FATHATAN,
        ACE_DAMMATAN,
        ACE_KASRATAN,
        ACE_FATHA,
        ACE_DAMMA,
        ACE_KASRA,
        ACE_SUKUN,
        ACE_DAGGER_ALIF,
        ACE_TATWEEL,
    ];

    foreach (ace_chars($text) as $character) {
        /*
         * Shadda contributes another structural occurrence of
         * the consonant that carries it.
         */
        if ($character === ACE_SHADDA) {
            if ($previousBase !== '') {
                $result .= $previousBase;
            }

            continue;
        }

        /*
         * Ordinary Arabic marks do not contribute letters or
         * structural positions.
         */
        if (in_array($character, $ignoredMarks, true)) {
            continue;
        }

        /*
         * Preserve the base letter and make it available for a
         * following shadda.
         */
        $result      .= $character;
        $previousBase = $character;
    }

    return $result;
}
/**
 * Determine whether a token ends with an exact non-empty Unicode suffix.
 *
 * This function performs only a surface-level comparison. It does not decide
 * whether the matched characters belong grammatically to the stem or suffix.
 */
function ace_ends_with(string $token, string $suffix): bool
{
    /*
     * ACE does not treat the empty string as a suffix candidate.
     */
    if ($suffix === '') {
        return false;
    }

    /*
     * Canonically equivalent Unicode strings should compare identically.
     *
     * Arabic text may contain combining marks encoded in different canonical
     * sequences. Normalization requires the PHP intl extension.
     */
    if (class_exists('Normalizer')) {
        $normalizedToken = Normalizer::normalize(
            $token,
            Normalizer::FORM_C
        );

        $normalizedSuffix = Normalizer::normalize(
            $suffix,
            Normalizer::FORM_C
        );

        if ($normalizedToken !== false) {
            $token = $normalizedToken;
        }

        if ($normalizedSuffix !== false) {
            $suffix = $normalizedSuffix;
        }
    }

    $tokenLength  = mb_strlen($token, 'UTF-8');
    $suffixLength = mb_strlen($suffix, 'UTF-8');

    if ($suffixLength > $tokenLength) {
        return false;
    }

    return mb_substr(
        $token,
        -$suffixLength,
        null,
        'UTF-8'
    ) === $suffix;
}
/**
 * Find and remove the longest eligible exact suffix.
 *
 * Important weak-letter rule:
 *
 * A single ا, و, or ي is structurally ambiguous because it may
 * be:
 *
 * - a suffix;
 * - a long-vowel letter;
 * - a weak radical.
 *
 * When such a candidate is a verbal suffix, its removal must
 * leave at least three Arabic base letters. This prevents:
 *
 *     دَعَا  -> دَعَ
 *
 * because the final ا represents the third weak radical.
 *
 * It still permits:
 *
 *     دَعَوَا -> دَعَوَ
 *
 * because three base letters د ع و remain.
 *
 * Empty suffix entries remain fallbacks only.
 *
 * @param array<int|string, mixed> $suffixCollection
 *
 * @return array{
 *     matched_suffix:string,
 *     matched_entry:?array,
 *     token_after_suffix:string
 * }
 */
function ace_remove_longest_suffix(
    string $token,
    array $suffixCollection
): array {
    $matchedSuffix = '';
    $matchedEntry  = null;
    $matchedStem   = $token;
    $bestLength    = -1;
    $bestOrder     = PHP_INT_MAX;

    /*
     * These are combining marks, not Arabic base letters.
     * They may be removed without structural restrictions.
     */
    $unconstrainedVowelMarks = [
        ACE_FATHA,
        ACE_KASRA,
        ACE_DAMMA,
    ];

    /*
     * A single occurrence of one of these letters may be either
     * suffixal or part of the lexical stem/root.
     */
    $ambiguousWeakLetters = [
        'ا',
        'و',
        'ي',
    ];

    $tokenLength  = mb_strlen($token, 'UTF-8');
    $initialLetter = ace_first_base_letter($token);

    /*
     * Use iteration order for deterministic equal-length ties,
     * including collections with string keys.
     */
    $collectionOrder = 0;

    foreach ($suffixCollection as $entry) {
        $currentOrder = $collectionOrder;
        $collectionOrder++;

        if (!is_array($entry)) {
            continue;
        }

        /*
         * Accept either `suffix` or the legacy `value` field.
         */
        $candidate = null;

        if (
            array_key_exists('suffix', $entry)
            && is_string($entry['suffix'])
        ) {
            $candidate = $entry['suffix'];
        } elseif (
            array_key_exists('value', $entry)
            && is_string($entry['value'])
        ) {
            $candidate = $entry['value'];
        }

        /*
         * Empty suffixes are fallback entries and must not
         * participate in active suffix matching.
         */
        if ($candidate === null || $candidate === '') {
            continue;
        }

        if (!ace_ends_with($token, $candidate)) {
            continue;
        }

        $candidateLength = mb_strlen(
            $candidate,
            'UTF-8'
        );

        $remainingLength = $tokenLength - $candidateLength;

        if ($remainingLength < 0) {
            continue;
        }

        $remainingToken = mb_substr(
            $token,
            0,
            $remainingLength,
            'UTF-8'
        );

        $remainingBaseLetters = ace_count_base_letters(
            $remainingToken
        );

        $isUnconstrainedVowelMark = in_array(
            $candidate,
            $unconstrainedVowelMarks,
            true
        );

        $isAmbiguousSingleWeakLetter = (
            ace_count_base_letters($candidate) === 1
            && in_array(
                ace_first_base_letter($candidate),
                $ambiguousWeakLetters,
                true
            )
        );

        /*
         * Determine whether this suffix entry is explicitly
         * classified as verbal.
         *
         * Support both:
         *
         *     'pos' => 'verb'
         *
         * and:
         *
         *     'pos' => ['verb']
         */
        $isVerbEntry = false;

        if (array_key_exists('pos', $entry)) {
            if (
                is_string($entry['pos'])
                && $entry['pos'] === 'verb'
            ) {
                $isVerbEntry = true;
            } elseif (
                is_array($entry['pos'])
                && in_array('verb', $entry['pos'], true)
            ) {
                $isVerbEntry = true;
            }
        }

        /*
         * Hard protection for weak verbal radicals.
         *
         * A single weak-letter verbal suffix may not be removed
         * when fewer than three base letters would remain.
         *
         * Therefore:
         *
         *     دَعَا
         *
         * does not lose its final radical ا.
         *
         * But:
         *
         *     دَعَوَا
         *
         * may lose the dual suffix ا because د ع و remain.
         */
        if (
            $isAmbiguousSingleWeakLetter
            && $isVerbEntry
            && $remainingBaseLetters < 3
        ) {
            continue;
        }

        /*
         * A single weak-letter candidate must have some explicit
         * structural evidence before it can be selected.
         */
        if ($isAmbiguousSingleWeakLetter) {
            $hasStructuralControl = (
                array_key_exists('min_stem_letters', $entry)
                || array_key_exists('required_initials', $entry)
                || array_key_exists('forbidden_initials', $entry)
                || (
                    array_key_exists('validator', $entry)
                    && is_callable($entry['validator'])
                )
            );

            if (!$hasStructuralControl) {
                continue;
            }
        }

        /*
         * Final short-vowel marks are safe surface endings.
         * Contextual constraints intended for letter suffixes
         * are not applied to them.
         */
        if (!$isUnconstrainedVowelMark) {
            /*
             * Optional minimum remaining base-letter count.
             */
            if (array_key_exists('min_stem_letters', $entry)) {
                $minimumStemLetters = max(
                    0,
                    (int) $entry['min_stem_letters']
                );

                if (
                    $remainingBaseLetters
                    < $minimumStemLetters
                ) {
                    continue;
                }
            }

            /*
             * Optional required initial letters.
             */
            if (
                array_key_exists('required_initials', $entry)
                && is_array($entry['required_initials'])
                && !in_array(
                    $initialLetter,
                    $entry['required_initials'],
                    true
                )
            ) {
                continue;
            }

            /*
             * Optional forbidden initial letters.
             */
            if (
                array_key_exists('forbidden_initials', $entry)
                && is_array($entry['forbidden_initials'])
                && in_array(
                    $initialLetter,
                    $entry['forbidden_initials'],
                    true
                )
            ) {
                continue;
            }

            /*
             * Optional morphology-specific validation.
             */
            if (
                array_key_exists('validator', $entry)
                && is_callable($entry['validator'])
            ) {
                $isValid = (bool) $entry['validator'](
                    $token,
                    $candidate,
                    $remainingToken,
                    $entry
                );

                if (!$isValid) {
                    continue;
                }
            }
        }

        /*
         * Prefer the longest eligible exact suffix.
         * Original collection order resolves equal-length ties.
         */
        if (
            $candidateLength > $bestLength
            || (
                $candidateLength === $bestLength
                && $currentOrder < $bestOrder
            )
        ) {
            $matchedSuffix = $candidate;
            $matchedEntry  = $entry;
            $matchedStem   = $remainingToken;
            $bestLength    = $candidateLength;
            $bestOrder     = $currentOrder;
        }
    }

    return [
        'matched_suffix'     => $matchedSuffix,
        'matched_entry'      => $matchedEntry,
        'token_after_suffix' => $matchedStem,
    ];
}
/**
 * Report the first plain letter and its broad onset group.
 *
 * This function performs only an initial-letter classification.
 * It does not determine tense, form, person, or part of speech.
 *
 * Possible imperfect person-marker letters are:
 * ي، ت، أ، ن
 *
 * Bare alif ا is reported separately because it may represent
 * hamzat al-wasl in an imperative, perfect derived form,
 * or verbal noun.
 *
 * Initial م is reported separately as a possible nominal or
 * participial marker.
 *
 * @return array{
 *   letter:string,
 *   value:string,
 *   group:string,
 *   is_imperfect_group_letter:bool,
 *   is_bare_alif:bool,
 *   is_mim_onset:bool
 * }
 */
function ace_detect_initial_group_letter(string $plainStem): array
{
    $letters = ace_chars($plainStem);
    $first   = $letters[0] ?? '';

    /*
     * These are possible imperfect person-marker letters only.
     * Their presence does not prove that the token is imperfect.
     */
    $imperfectLetters = [
        'ي' => 'y',
        'ت' => 't',
        'أ' => 'a',
        'ن' => 'n',
    ];

    $isImperfectLetter = array_key_exists(
        $first,
        $imperfectLetters
    );

    if ($isImperfectLetter) {
        $group = 'possible_imperfect_person_marker';
        $value = $imperfectLetters[$first];
    } elseif ($first === 'ا') {
        $group = 'bare_alif';
        $value = 'a';
    } elseif ($first === 'م') {
        $group = 'mim_onset';
        $value = 'm';
    } else {
        $group = '';
        $value = '';
    }

    return [
        'letter'                    => $first,
        'value'                     => $value,
        'group'                     => $group,
        'is_imperfect_group_letter' => $isImperfectLetter,
        'is_bare_alif'              => $first === 'ا',
        'is_mim_onset'              => $first === 'م',
    ];
}
/*
 * ============================================================
 * 1. SUFFIX HELPERS
 * ============================================================
 */

/**
 * Collect unique, non-zero suffix strings and sort them from
 * longest to shortest.
 *
 * The master collection may store the suffix string under
 * either `suffix` or `value`.
 *
 * This function only prepares suffix strings. It does not decide
 * whether a suffix is grammatically valid for a particular token,
 * nor does it remove stacked suffixes.
 *
 * Equal-length suffixes retain their original collection order.
 *
 * @return list<string>
 */
function ace_collect_sorted_suffixes(array $masterSuffixes): array
{
    $collected = [];
    $seen      = [];

    foreach ($masterSuffixes as $index => $suffixEntry) {
        if (!is_array($suffixEntry)) {
            continue;
        }

        $suffix = null;

        if (
            array_key_exists('suffix', $suffixEntry)
            && is_string($suffixEntry['suffix'])
        ) {
            $suffix = $suffixEntry['suffix'];
        } elseif (
            array_key_exists('value', $suffixEntry)
            && is_string($suffixEntry['value'])
        ) {
            $suffix = $suffixEntry['value'];
        }

        /*
         * The zero suffix is a fallback result and must never
         * enter the real suffix-matching loop.
         */
        if ($suffix === null || $suffix === '') {
            continue;
        }

        /*
         * Deduplicate without losing the position of the first
         * occurrence. The first entry has precedence when two
         * entries contain the same suffix string.
         */
        if (isset($seen[$suffix])) {
            continue;
        }

        $seen[$suffix] = true;

        $collected[] = [
            'suffix' => $suffix,
            'length' => mb_strlen($suffix, 'UTF-8'),
            'index'  => is_int($index)
                ? $index
                : count($collected),
        ];
    }

    usort(
        $collected,
        static function (array $entryA, array $entryB): int {
            /*
             * Primary order: longest suffix first.
             */
            $lengthComparison =
                $entryB['length'] <=> $entryA['length'];

            if ($lengthComparison !== 0) {
                return $lengthComparison;
            }

            /*
             * Secondary order: preserve collection precedence
             * for suffixes of equal Unicode length.
             */
            return $entryA['index'] <=> $entryB['index'];
        }
    );

    return array_column($collected, 'suffix');
}

/**
 * Find the first master suffix entry whose Arabic suffix equals
 * the matched suffix.
 */
function ace_find_suffix_entry(
    string $matchedSuffix,
    array $masterSuffixes
): array {
    if ($matchedSuffix === '') {
        return [];
    }

    foreach ($masterSuffixes as $suffixEntry) {
        if (
            is_array($suffixEntry)
            && isset($suffixEntry['suffix'])
            && is_string($suffixEntry['suffix'])
            && $suffixEntry['suffix'] === $matchedSuffix
        ) {
            return $suffixEntry;
        }
    }

    return [];
}

/**
 * Reconstruct a missing final radical for a two-radical stem.
 *
 * This applies when morphology has neutralized or replaced the
 * third radical, as in:
 *
 *     دَعَا
 *     يَدْعُونَ
 *
 * @param array<int, string> $radicals
 *
 * @return array<int, array{
 *     root:string,
 *     root_letters:array<int, string>,
 *     reconstruction:string
 * }>
 */
function ace_reconstruct_missing_final_radical(
    array $radicals
): array {
    if (count($radicals) !== 2) {
        return [];
    }

    [$r0, $r1] = array_values($radicals);

    $candidates = [];

    foreach (['و', 'ي', 'ء'] as $r2) {
        $rootLetters = [$r0, $r1, $r2];

        $candidates[] = [
            'root'           => implode('', $rootLetters),
            'root_letters'   => $rootLetters,
            'reconstruction' => 'missing_final_radical',
        ];
    }

    return $candidates;
}
/**
 * Read the grammatical type from the matched suffix entry.
 *
 * The function supports several possible key names so that the
 * analyzer remains compatible while the suffix array evolves.
 */
function ace_get_suffix_type(array $suffixEntry): string
{
    $possibleKeys = [
        'suffix_type',
        'type',
        'category',
        'family',
        'tense_mood',
        'mood',
    ];

    foreach ($possibleKeys as $key) {
        if (
            isset($suffixEntry[$key])
            && is_string($suffixEntry[$key])
            && $suffixEntry[$key] !== ''
        ) {
            return $suffixEntry[$key];
        }
    }

    return '';
}


/**
 * Remove the longest matching suffix and return all suffix-related
 * values together.
 */
function ace_match_longest_suffix(
    string $token,
    array $masterSuffixes
): array {
    $tokenLength = mb_strlen(
        $token,
        'UTF-8'
    );

    $matchedSuffix = '';
    $matchedSuffixLength = 0;
    $matchedSuffixEntry = [];
    $markedStem = $token;

    $sortedSuffixes = ace_collect_sorted_suffixes(
        $masterSuffixes
    );

    foreach ($sortedSuffixes as $suffix) {
        $suffixLength = mb_strlen(
            $suffix,
            'UTF-8'
        );

        if ($suffixLength > $tokenLength) {
            continue;
        }

        $tokenEnding = mb_substr(
            $token,
            -$suffixLength,
            null,
            'UTF-8'
        );

        if ($tokenEnding !== $suffix) {
            continue;
        }

        $matchedSuffix = $suffix;
        $matchedSuffixLength = $suffixLength;

        $markedStem = mb_substr(
            $token,
            0,
            $tokenLength - $suffixLength,
            'UTF-8'
        );

        $matchedSuffixEntry = ace_find_suffix_entry(
            $matchedSuffix,
            $masterSuffixes
        );

        break;
    }

    return [
        'matched_suffix'        => $matchedSuffix,
        'matched_suffix_entry'  => $matchedSuffixEntry,
        'matched_suffix_type'   => ace_get_suffix_type(
            $matchedSuffixEntry
        ),
        'matched_suffix_length' => $matchedSuffixLength,
        'marked_stem'           => $markedStem,
    ];
}


/*
 * ============================================================
 * 2. CLUSTER HELPERS
 * ============================================================
 */

/**
 * Determine whether a character is an Arabic diacritic used by
 * the analyzer.
 */
function ace_is_analysis_diacritic(string $character): bool
{
    return in_array(
        $character,
        [
            ACE_FATHATAN,
            ACE_DAMMATAN,
            ACE_KASRATAN,
            ACE_FATHA,
            ACE_DAMMA,
            ACE_KASRA,
            ACE_SHADDA,
            ACE_SUKUN,
            ACE_DAGGER_ALIF,
        ],
        true
    );
}


/**
 * Convert a marked Arabic string into letter-plus-mark clusters.
 *
 * Each cluster has this structure:
 *
 * [
 *     'letter' => 'ر',
 *     'marks'  => ['َ', 'ّ'],
 *     'text'   => 'رَّ',
 * ]
 */
function ace_build_clusters(string $markedText): array
{
    $clusters = [];

    foreach (ace_chars($markedText) as $character) {
        /*
         * Tatweel is decorative and has no morphological value.
         */
        if ($character === ACE_TATWEEL) {
            continue;
        }

        /*
         * Attach a diacritic to the preceding Arabic letter.
         */
        if (ace_is_analysis_diacritic($character)) {
            $lastIndex = count($clusters) - 1;

            /*
             * Ignore an unattached mark at the beginning of the text.
             */
            if ($lastIndex < 0) {
                continue;
            }

            $clusters[$lastIndex]['marks'][] = $character;
            $clusters[$lastIndex]['text'] .= $character;

            continue;
        }

        /*
         * Start a new letter cluster.
         */
        $clusters[] = [
            'letter' => $character,
            'marks'  => [],
            'text'   => $character,
        ];
    }

    return array_values($clusters);
}

/**
 * Return only valid, non-empty letters from a cluster collection.
 */
function ace_cluster_letters(array $clusters): array
{
    $letters = [];

    foreach ($clusters as $cluster) {
        if (!is_array($cluster)) {
            continue;
        }

        $letter = isset($cluster['letter'])
            && is_string($cluster['letter'])
                ? $cluster['letter']
                : '';

        if ($letter === '') {
            continue;
        }

        $letters[] = $letter;
    }

    return $letters;
}

/**
 * Safely return the mark array belonging to a cluster.
 */
/**
 * Extract Arabic combining marks from one structural cluster.
 *
 * A cluster may arrive as:
 * - a string such as "تَ"
 * - an array of Unicode characters
 * - an array containing cluster metadata
 */
function ace_cluster_marks($cluster): array
{
    /*
     * Resolve the cluster into a Unicode string.
     */
    if (is_string($cluster)) {
        $clusterText = $cluster;
    } elseif (is_array($cluster)) {
        /*
         * Support structured cluster arrays.
         */
        if (
            isset($cluster['cluster'])
            && is_string($cluster['cluster'])
        ) {
            $clusterText = $cluster['cluster'];
        } elseif (
            isset($cluster['text'])
            && is_string($cluster['text'])
        ) {
            $clusterText = $cluster['text'];
        } elseif (
            isset($cluster['value'])
            && is_string($cluster['value'])
        ) {
            $clusterText = $cluster['value'];
        } else {
            /*
             * Support arrays of Unicode characters.
             * Ignore nested arrays and other non-string values.
             */
            $characters = [];

            foreach ($cluster as $value) {
                if (is_string($value)) {
                    $characters[] = $value;
                }
            }

            $clusterText = implode('', $characters);
        }
    } else {
        $clusterText = '';
    }

    if ($clusterText === '') {
        return [];
    }

    /*
     * Extract Arabic vowel marks, sukun, shadda,
     * dagger alif and related combining marks.
     */
    preg_match_all(
        '/[\x{0610}-\x{061A}\x{064B}-\x{065F}\x{0670}\x{06D6}-\x{06ED}]/u',
        $clusterText,
        $matches
    );

    if (
        !isset($matches[0])
        || !is_array($matches[0])
    ) {
        return [];
    }

    return array_values(
        array_filter(
            $matches[0],
            static function ($mark): bool {
                return is_string($mark)
                    && $mark !== '';
            }
        )
    );
}

/**
 * Return the principal vowel mark from a cluster.
 */
/**
 * Return the first vowel mark found in a cluster.
 *
 * Accepts either:
 * - a Unicode cluster string, such as "تَ"
 * - a structured cluster array
 */
function ace_get_cluster_vowel($cluster): string
{
    /*
     * Structured cluster arrays may already contain a vowel.
     */
    if (is_array($cluster)) {
        foreach (['vowel', 'vowel_mark', 'harakah'] as $key) {
            if (
                isset($cluster[$key])
                && is_string($cluster[$key])
                && $cluster[$key] !== ''
            ) {
                return $cluster[$key];
            }
        }

        /*
         * Search a marks array.
         */
        if (
            isset($cluster['marks'])
            && is_array($cluster['marks'])
        ) {
            foreach ($cluster['marks'] as $mark) {
                if (
                    is_string($mark)
                    && ace_is_short_vowel_mark($mark)
                ) {
                    return $mark;
                }
            }
        }

        /*
         * Some cluster arrays use numeric elements.
         */
        foreach ($cluster as $value) {
            if (
                is_string($value)
                && ace_is_short_vowel_mark($value)
            ) {
                return $value;
            }
        }

        /*
         * Fall back to a textual cluster value.
         */
        foreach (['cluster', 'text', 'value', 'raw'] as $key) {
            if (
                isset($cluster[$key])
                && is_string($cluster[$key])
            ) {
                $cluster = $cluster[$key];
                break;
            }
        }
    }

    if (!is_string($cluster) || $cluster === '') {
        return '';
    }

    /*
     * Arabic short vowels:
     * fathah, dammah, kasrah,
     * fathatan, dammatan, kasratan.
     */
    if (
        preg_match(
            '/[\x{064B}-\x{0650}]/u',
            $cluster,
            $matches
        ) === 1
    ) {
        return $matches[0];
    }

    return '';
}
/**
 * Determine whether a Unicode character is an Arabic short-vowel mark.
 */
function ace_is_short_vowel_mark($mark): bool
{
    if (!is_string($mark) || $mark === '') {
        return false;
    }

    return preg_match(
        '/^[\x{064B}-\x{0650}]$/u',
        $mark
    ) === 1;
}

/**
 * Return the base letter from a cluster.
 */
 /**
 * Return the consonantal letter stored in a cluster.
 */
 //-------------------------
 /**
 * Convert an ACE value into safe displayable text.
 *
 * This function should be used only at boundaries where a string
 * is required. It preserves useful cluster and mark information.
 */
function ace_value_to_text($value): string
{
    if ($value === null) {
        return '';
    }

    if (is_string($value)) {
        return $value;
    }

    if (is_int($value) || is_float($value)) {
        return (string) $value;
    }

    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }

    if (!is_array($value)) {
        return '';
    }

    /*
     * A single structural cluster.
     */
    if (
        isset($value['text'])
        && is_string($value['text'])
    ) {
        return $value['text'];
    }

    /*
     * A cluster without a prebuilt text field.
     */
    if (
        isset($value['letter'])
        && is_string($value['letter'])
    ) {
        return $value['letter']
            . ace_marks_to_text($value['marks'] ?? '');
    }

    /*
     * A list of marks or a list of clusters.
     */
    $parts = [];

    foreach ($value as $item) {
        $text = ace_value_to_text($item);

        if ($text !== '') {
            $parts[] = $text;
        }
    }

    return implode('', $parts);
}


/**
 * Convert a mark value into a plain diacritic string.
 */
function ace_marks_to_text($marks): string
{
    if ($marks === null) {
        return '';
    }

    if (is_string($marks)) {
        return $marks;
    }

    if (!is_array($marks)) {
        return '';
    }

    $result = '';

    foreach ($marks as $mark) {
        if (is_string($mark)) {
            $result .= $mark;
            continue;
        }

        if (is_array($mark)) {
            $result .= ace_marks_to_text($mark);
        }
    }

    return $result;
}


/**
 * Return the base letter from a cluster, letter, or scalar value.
 */
function ace_cluster_letter($cluster): string
{
    if (is_string($cluster)) {
        return $cluster;
    }

    if (!is_array($cluster)) {
        return '';
    }

    if (
        isset($cluster['letter'])
        && is_string($cluster['letter'])
    ) {
        return $cluster['letter'];
    }

    if (
        isset($cluster['text'])
        && is_string($cluster['text'])
    ) {
        return ace_remove_diacritics($cluster['text']);
    }

    return '';
}


/**
 * Return the marks belonging to a cluster.
 */
function ace_cluster_marks_safe($cluster): string
{
    if (!is_array($cluster)) {
        return '';
    }

    return ace_marks_to_text(
        $cluster['marks'] ?? ''
    );
}

/**
 * Return all marks in a cluster as one Unicode string.
 */
function ace_cluster_marks_text($cluster): string
{
    if (!is_array($cluster)) {
        return '';
    }

    $marks = $cluster['marks'] ?? '';

    if (is_string($marks)) {
        return $marks;
    }

    if (is_array($marks)) {
        $cleanMarks = [];

        foreach ($marks as $mark) {
            if (is_string($mark)) {
                $cleanMarks[] = $mark;
            }
        }

        return implode('', $cleanMarks);
    }

    return '';
}
//--------------------
/**
 * Convert any ACE cluster representation into its base letter.
 */
function ace_cluster_letter_value($cluster): string
{
    if (is_array($cluster)) {
        $letter = $cluster['letter'] ?? '';

        return is_scalar($letter)
            ? (string) $letter
            : '';
    }

    return is_scalar($cluster)
        ? (string) $cluster
        : '';
}

/**
 * Convert cluster marks into a normalized array of strings.
 */
function ace_cluster_marks_array($cluster): array
{
    if (!is_array($cluster)) {
        return [];
    }

    $marks = $cluster['marks'] ?? [];

    if (is_string($marks)) {
        return $marks === '' ? [] : [$marks];
    }

    if (!is_array($marks)) {
        return [];
    }

    $normalized = [];

    foreach ($marks as $mark) {
        if (is_scalar($mark) && (string) $mark !== '') {
            $normalized[] = (string) $mark;
        }
    }

    return $normalized;
}

/**
 * Convert cluster marks into one printable string.
 */
function ace_cluster_marks_string($cluster): string
{
    return implode(
        '',
        ace_cluster_marks_array($cluster)
    );
}

/**
 * Convert an ACE cluster into its complete written text.
 */
function ace_cluster_text_value($cluster): string
{
    if (is_array($cluster)) {
        $text = $cluster['text'] ?? '';

        if (is_scalar($text) && (string) $text !== '') {
            return (string) $text;
        }

        return ace_cluster_letter_value($cluster)
            . ace_cluster_marks_string($cluster);
    }

    return is_scalar($cluster)
        ? (string) $cluster
        : '';
}

/**
 * Safely convert a mixed ACE value for HTML/table output.
 */
function ace_display_value($value): string
{
    if ($value === null) {
        return '';
    }

    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }

    if (is_scalar($value)) {
        return (string) $value;
    }

    if (!is_array($value)) {
        return '';
    }

    /*
     * A single structural cluster.
     */
    if (
        array_key_exists('letter', $value) ||
        array_key_exists('marks', $value) ||
        array_key_exists('text', $value)
    ) {
        return ace_cluster_text_value($value);
    }

    /*
     * A normal indexed or associative array.
     */
    $parts = [];

    foreach ($value as $key => $item) {
        $display = ace_display_value($item);

        if ($display === '') {
            continue;
        }

        if (is_string($key)) {
            $parts[] = $key . '=' . $display;
        } else {
            $parts[] = $display;
        }
    }

    return implode(' | ', $parts);
}

/**
 * Return all marks belonging to a cluster as one string.
 */
function ace_cluster_marks_value($cluster): string
{
    if (!is_array($cluster)) {
        return '';
    }

    $marks = $cluster['marks'] ?? '';

    if (is_array($marks)) {
        $marks = array_filter(
            $marks,
            static function ($mark): bool {
                return is_string($mark) && $mark !== '';
            }
        );

        return implode('', $marks);
    }

    return is_string($marks)
        ? $marks
        : '';
}

/**
 * Convert an arbitrary scalar or list into printable text.
 */
 //---------------------
 /**
 * Safely convert any ACE value into displayable text.
 */

/**
 * Return only combining marks from a marks array or scalar value.
 */
function ace_marks_to_string(mixed $marks): string
{
    if (is_array($marks)) {
        $marks = array_values(
            array_filter(
                $marks,
                static fn ($mark): bool =>
                    is_scalar($mark) && (string) $mark !== ''
            )
        );

        return implode('', array_map('strval', $marks));
    }

    return is_scalar($marks) ? (string) $marks : '';
}
function ace_printable_value($value, string $separator = ' | '): string
{
    if ($value === null) {
        return '';
    }

    if (is_bool($value)) {
        return $value
            ? 'true'
            : 'false';
    }

    if (is_string($value)) {
        return $value;
    }

    if (is_int($value) || is_float($value)) {
        return (string) $value;
    }

    if (!is_array($value)) {
        return '';
    }

    $parts = [];

    foreach ($value as $key => $item) {
        if (
            is_array($item)
            && (
                array_key_exists('letter', $item)
                || array_key_exists('text', $item)
            )
        ) {
            $text = ace_cluster_text_value($item);

            if ($text !== '') {
                $parts[] = $text;
            }

            continue;
        }

        $text = ace_printable_value(
            $item,
            $separator
        );

        if ($text === '') {
            continue;
        }

        if (!is_int($key)) {
            $parts[] = $key . '=' . $text;
        } else {
            $parts[] = $text;
        }
    }

    return implode(
        $separator,
        $parts
    );
}
/**
 * Rebuild one marked cluster as an Arabic string.
 */

/**
 * Rebuild several marked clusters as one Arabic string.
 */
function ace_clusters_to_string(array $clusters): string
{
    $output = '';

    foreach ($clusters as $cluster) {
        if (!is_array($cluster)) {
            continue;
        }

        $output .= ace_cluster_to_string(
            $cluster
        );
    }

    return $output;
}


/*
 * ============================================================
 * 3. STRUCTURAL MARKER HELPERS
 * ============================================================
 */

/**
 * Remove and report structural form markers.
 *
 * The function works on a copy of the cluster collection. It does
 * not modify marked_stem or structural_stem.
 */
function ace_extract_form_markers(
    array $clusters,
    string $existingVn = ''
): array {
    $workingClusters = array_values(
        $clusters
    );

    $vn = $existingVn;
    $vm = '';
    $fs = '';

    $letters = ace_cluster_letters(
        $workingClusters
    );

    /*
     * Fallback imperfect-marker detection.
     *
     * Normally ace_extract_initial_markers() supplies vn and
     * removes it from structural_stem. This block is retained for
     * compatibility when the helper does not remove it.
     */
    if (
        $vn === ''
        && isset($letters[0], $letters[1])
        && in_array(
            $letters[0],
            ['ي', 'ت', 'أ', 'ا', 'ن', 'م'],
            true
        )
    ) {
        $secondLetter = $letters[1];

        $isClearImperfectStructure = false;

        if (
            in_array(
                $secondLetter,
                ['ت', 'ن'],
                true
            )
        ) {
            $isClearImperfectStructure = true;
        }

        if (
            $secondLetter === 'س'
            && isset($letters[2])
            && $letters[2] === 'ت'
        ) {
            $isClearImperfectStructure = true;
        }

        if (
            count($letters) === 4
            || count($letters) === 5
        ) {
            $isClearImperfectStructure = true;
        }

        if ($isClearImperfectStructure) {
            $vn = $letters[0];

            array_shift(
                $workingClusters
            );
        }
    }

    $letters = ace_cluster_letters(
        $workingClusters
    );

    /*
     * Form X perfect:
     *
     * اِسْتَدْرَس
     */
    if (
        isset($letters[0], $letters[1], $letters[2])
        && $letters[0] === 'ا'
        && $letters[1] === 'س'
        && $letters[2] === 'ت'
    ) {
        $formClusters = array_slice(
            $workingClusters,
            0,
            3
        );

        $fs = ace_clusters_to_string(
            $formClusters
        );

        array_splice(
            $workingClusters,
            0,
            3
        );
    } elseif (
        isset($letters[0], $letters[1])
        && $letters[0] === 'س'
        && $letters[1] === 'ت'
        && $vn !== ''
    ) {
        /*
         * Form X imperfect after vn has already been removed:
         *
         * يَسْتَدْرِس -> سْتَدْرِس
         */
        $formClusters = array_slice(
            $workingClusters,
            0,
            2
        );

        $fs = ace_clusters_to_string(
            $formClusters
        );

        array_splice(
            $workingClusters,
            0,
            2
        );
    }

    $letters = ace_cluster_letters(
        $workingClusters
    );

    /*
     * Form VII perfect:
     *
     * اِنْدَرَس
     */
    if (
        $fs === ''
        && isset($letters[0], $letters[1])
        && $letters[0] === 'ا'
        && $letters[1] === 'ن'
    ) {
        $formClusters = array_slice(
            $workingClusters,
            0,
            2
        );

        $fs = ace_clusters_to_string(
            $formClusters
        );

        array_splice(
            $workingClusters,
            0,
            2
        );
    } elseif (
        $fs === ''
        && isset($letters[0])
        && $letters[0] === 'ن'
        && $vn !== ''
    ) {
        /*
         * Form VII imperfect after vn removal:
         *
         * يَنْدَرِس -> نْدَرِس
         */
        $fs = ace_cluster_to_string(
            $workingClusters[0]
        );

        array_shift(
            $workingClusters
        );
    }

    $letters = ace_cluster_letters(
        $workingClusters
    );

    /*
     * Initial alif of Forms VIII and IX.
     */
    if (
        $fs === ''
        && isset($letters[0])
        && $letters[0] === 'ا'
    ) {
        $fs = ace_cluster_to_string(
            $workingClusters[0]
        );

        array_shift(
            $workingClusters
        );
    }

    $letters = ace_cluster_letters(
        $workingClusters
    );

    /*
     * Forms V and VI derivational ت.
     */
    if (
        isset($letters[0])
        && $letters[0] === 'ت'
        && count($workingClusters) >= 4
    ) {
        $vm = ace_cluster_to_string(
            $workingClusters[0]
        );

        array_shift(
            $workingClusters
        );
    }

    return [
        'vn'                  => $vn,
        'vm'                  => $vm,
        'fs'                  => $fs,
        'structural_clusters' => array_values(
            $workingClusters
        ),
    ];
}


/*
 * ============================================================
 * 4. ROOT-CANDIDATE HELPERS
 * ============================================================
 */

/**
 * Remove long-vowel clusters from strong-root candidates.
 */
function ace_filter_root_candidates(array $clusters): array
{
    $rootCandidates = [];

    foreach ($clusters as $cluster) {
        if (!is_array($cluster)) {
            continue;
        }

        $letter = (string) (
            $cluster['letter']
            ?? ''
        );

        if (
            in_array(
                $letter,
                ['ا', 'و', 'ي', 'ى'],
                true
            )
        ) {
            continue;
        }

        $rootCandidates[] = $cluster;
    }

    return array_values(
        $rootCandidates
    );
}


/**
 * Determine whether four consonant clusters represent the
 * standard non-fully-assimilated Form VIII arrangement:
 *
 * r0 + inserted marker + r1 + r2
 */
function ace_detect_form_eight_candidates(
    array $rootCandidates
): array {
    $candidateCount = count(
        $rootCandidates
    );

    if ($candidateCount < 4) {
        return [
            'is_form_eight' => false,
        ];
    }

    /*
     * Use the final four consonantal clusters. This protects the
     * analysis if an earlier non-root marker remains present.
     */
    $fourCandidates = array_slice(
        $rootCandidates,
        -4
    );

    $candidateR0 = $fourCandidates[0] ?? [];
    $marker = $fourCandidates[1] ?? [];
    $candidateR1 = $fourCandidates[2] ?? [];
    $candidateR2 = $fourCandidates[3] ?? [];

    $r0Letter = (string) (
        $candidateR0['letter']
        ?? ''
    );

    $markerLetter = (string) (
        $marker['letter']
        ?? ''
    );

    $r1Letter = (string) (
        $candidateR1['letter']
        ?? ''
    );

    $r2Letter = (string) (
        $candidateR2['letter']
        ?? ''
    );

    /*
     * Common written realizations of the Form VIII marker.
     */
    $isPossibleMarker = in_array(
        $markerLetter,
        ['ت', 'ط', 'د'],
        true
    );

    $r0Marks = ace_cluster_marks(
        $candidateR0
    );

    $hasSukunBeforeMarker = in_array(
        ACE_SUKUN,
        $r0Marks,
        true
    );

    $isFormEight =
        $r0Letter !== ''
        && $r1Letter !== ''
        && $r2Letter !== ''
        && $isPossibleMarker
        && $hasSukunBeforeMarker;

    if (!$isFormEight) {
        return [
            'is_form_eight' => false,
        ];
    }

    return [
        'is_form_eight' => true,
        'marker'        => $marker,
        'marker_letter' => $markerLetter,
        'root_clusters' => [
            $candidateR0,
            $candidateR1,
            $candidateR2,
        ],
    ];
}


/**
 * Perform root extraction, including the special four-cluster
 * Form VIII arrangement.
 */
function ace_extract_root_analysis(
    array $structuralClusters,
    string $currentVm = '',
    string $currentFs = ''
): array {
    $rootCandidates = ace_filter_root_candidates(
        $structuralClusters
    );

    /*
     * Form VIII must be checked before reducing the candidate
     * collection to three clusters.
     */
    $formEight = ace_detect_form_eight_candidates(
        $rootCandidates
    );

    $vm = $currentVm;
    $fs = $currentFs;

    if (
        ($formEight['is_form_eight'] ?? false) === true
    ) {
        $rootClusters = $formEight['root_clusters'] ?? [];

        /*
         * Keep the actual marked form sign when it is available.
         * Otherwise use the form number as a structural label.
         */
        if ($fs === '') {
            $fs = 'VIII';
        }

        $marker = $formEight['marker'] ?? [];

        if (is_array($marker)) {
            $vm = ace_cluster_to_string(
                $marker
            );
        }
    } else {
        $rootClusters = $rootCandidates;

        if (count($rootClusters) > 3) {
            $rootClusters = array_slice(
                $rootClusters,
                -3
            );
        }
    }

    $positions = ace_assign_root_positions(
        $rootClusters
    );

    $positions['vm'] = $vm;
    $positions['fs'] = $fs;

    return $positions;
}


/*
 * ============================================================
 * 5. MAIN ANALYZER
 * ============================================================
 */

function ace_simple_analyze(string $token): array
{
    global $master_suffixes, $master_prefixes;

    /*
     * --------------------------------------------------------
     * Step 1: token and suffix analysis
     * --------------------------------------------------------
     */

    $tokenLength = mb_strlen(
        $token,
        'UTF-8'
    );

    $suffixAnalysis = ace_match_longest_suffix(
        $token,
        $master_suffixes
    );

    $matchedSuffix = (string) (
        $suffixAnalysis['matched_suffix']
        ?? ''
    );

    $matchedSuffixEntry = is_array(
        $suffixAnalysis['matched_suffix_entry']
        ?? null
    )
        ? $suffixAnalysis['matched_suffix_entry']
        : [];

    $suffixType = (string) (
        $suffixAnalysis['matched_suffix_type']
        ?? ''
    );

    $matchedSuffixLength = (int) (
        $suffixAnalysis['matched_suffix_length']
        ?? 0
    );

    $markedStem = (string) (
        $suffixAnalysis['marked_stem']
        ?? $token
    );

    $tokenLengthAfterSuffixRemoval = mb_strlen(
        $markedStem,
        'UTF-8'
    );

    /*
     * --------------------------------------------------------
     * Step 2: extract conjunction, future marker, and vn
     * --------------------------------------------------------
     *
     * Prefixes are reported, but they are not removed here.
     * The only removals performed before this stage are suffix
     * removal and the structural handling performed by
     * ace_extract_initial_markers().
     */

    $initialMarkers = ace_extract_initial_markers(
        $markedStem
    );

    $conjunction = (string) (
        $initialMarkers['conjunction']
        ?? ''
    );

    $futureMarker = (string) (
        $initialMarkers['future_marker']
        ?? ''
    );

    $vn = (string) (
        $initialMarkers['vn']
        ?? ''
    );

    $structuralStem = (string) (
        $initialMarkers['structural_stem']
        ?? $markedStem
    );

/*
 * --------------------------------------------------------
 * Step 3: prefix analysis
 * --------------------------------------------------------
 *
 * Prefix matching is informational only. It must not modify
 * marked_stem or structural_stem.
 */

$prefixAnalysis = [];

if (
    isset($master_prefixes)
    && is_array($master_prefixes)
    && !empty($master_prefixes)
) {
    $prefixAnalysis = ace_match_longest_prefix(
        $markedStem,
        $master_prefixes
    );

    if (!is_array($prefixAnalysis)) {
        $prefixAnalysis = [];
    }
}

/*
 * --------------------------------------------------------
 * Safely collect the matched prefix string
 * --------------------------------------------------------
 */

$rawMatchedPrefix = (
    $prefixAnalysis['matched_prefix']
    ?? ''
);

$matchedPrefix = is_string($rawMatchedPrefix)
    ? $rawMatchedPrefix
    : '';

/*
 * --------------------------------------------------------
 * Collect the primary matched entry
 * --------------------------------------------------------
 */

$rawMatchedPrefixEntry = (
    $prefixAnalysis['matched_prefix_entry']
    ?? []
);

$matchedPrefixEntry = is_array($rawMatchedPrefixEntry)
    ? $rawMatchedPrefixEntry
    : [];

/*
 * --------------------------------------------------------
 * Collect all entries sharing the longest prefix
 * --------------------------------------------------------
 */

$rawMatchedPrefixEntries = (
    $prefixAnalysis['matched_prefix_entries']
    ?? []
);

$matchedPrefixEntries = is_array($rawMatchedPrefixEntries)
    ? $rawMatchedPrefixEntries
    : [];

/*
 * Compatibility fallback:
 *
 * Some versions of ace_match_longest_prefix() may return only
 * matched_prefix_entry instead of matched_prefix_entries.
 */

if (
    empty($matchedPrefixEntries)
    && !empty($matchedPrefixEntry)
) {
    $matchedPrefixEntries = [
        $matchedPrefixEntry,
    ];
}

/*
 * Remove malformed entries.
 */

$matchedPrefixEntries = array_values(
    array_filter(
        $matchedPrefixEntries,
        static function ($entry): bool {
            return is_array($entry);
        }
    )
);

/*
 * --------------------------------------------------------
 * Determine matched prefix length
 * --------------------------------------------------------
 */

$rawMatchedPrefixLength = (
    $prefixAnalysis['matched_prefix_length']
    ?? null
);

if (is_int($rawMatchedPrefixLength)) {
    $matchedPrefixLength = $rawMatchedPrefixLength;
} elseif (
    is_numeric($rawMatchedPrefixLength)
    && !is_array($rawMatchedPrefixLength)
) {
    $matchedPrefixLength = (int) $rawMatchedPrefixLength;
} else {
    $matchedPrefixLength = $matchedPrefix !== ''
        ? mb_strlen($matchedPrefix, 'UTF-8')
        : 0;
}

/*
 * --------------------------------------------------------
 * Step 4: collect prefix information
 * --------------------------------------------------------
 *
 * Several entries may contain the same Arabic prefix while
 * carrying different grammatical classifications.
 */

$prefixFamilies = [];
$prefixFormCandidates = [];
$prefixTenseCandidates = [];
$prefixVoiceCandidates = [];
$prefixMoodCandidates = [];

foreach ($matchedPrefixEntries as $prefixEntry) {
    /*
     * This check is retained in case malformed data is introduced
     * after the filtering operation above.
     */

    if (!is_array($prefixEntry)) {
        continue;
    }

    $rawFamily = (
        $prefixEntry['family']
        ?? ''
    );

    $family = is_string($rawFamily)
        ? $rawFamily
        : '';

    $rawForm = (
        $prefixEntry['form']
        ?? $family
    );

    $form = is_string($rawForm)
        ? $rawForm
        : '';

    $rawTense = (
        $prefixEntry['tense']
        ?? ''
    );

    $tense = is_string($rawTense)
        ? $rawTense
        : '';

    $rawVoice = (
        $prefixEntry['voice']
        ?? ''
    );

    $voice = is_string($rawVoice)
        ? $rawVoice
        : '';

    $rawMood = (
        $prefixEntry['mood']
        ?? ''
    );

    $mood = is_string($rawMood)
        ? $rawMood
        : '';

    if ($family !== '') {
        $prefixFamilies[] = $family;
    }

    if ($form !== '') {
        $prefixFormCandidates[] = $form;
    }

    if ($tense !== '') {
        $prefixTenseCandidates[] = $tense;
    }

    if ($voice !== '') {
        $prefixVoiceCandidates[] = $voice;
    }

    if ($mood !== '') {
        $prefixMoodCandidates[] = $mood;
    }
}

/*
 * Remove duplicate grammatical classifications.
 */

$prefixFamilies = array_values(
    array_unique($prefixFamilies)
);

$prefixFormCandidates = array_values(
    array_unique($prefixFormCandidates)
);

$prefixTenseCandidates = array_values(
    array_unique($prefixTenseCandidates)
);

$prefixVoiceCandidates = array_values(
    array_unique($prefixVoiceCandidates)
);

$prefixMoodCandidates = array_values(
    array_unique($prefixMoodCandidates)
);

/*
 * Consolidated prefix information.
 */

$prefixInformation = [
    'matched_prefix' => $matchedPrefix,

    'matched_prefix_length' => $matchedPrefixLength,

    'matched_prefix_entry' => $matchedPrefixEntry,

    'matched_prefix_entries' => $matchedPrefixEntries,

    'prefix_families' => $prefixFamilies,

    'prefix_form_candidates' => $prefixFormCandidates,

    'prefix_tense_candidates' => $prefixTenseCandidates,

    'prefix_voice_candidates' => $prefixVoiceCandidates,

    'prefix_mood_candidates' => $prefixMoodCandidates,
];
    /*
     * --------------------------------------------------------
     * Step 5: basic stem representations
     * --------------------------------------------------------
     */

    $plainStem = ace_remove_diacritics(
        $markedStem
    );

    $plainStemLength = mb_strlen(
        $plainStem,
        'UTF-8'
    );

    $expandedSkeleton = ace_build_expanded_skeleton(
        $markedStem
    );

    $letters = ace_chars(
        $expandedSkeleton
    );

    $initial = ace_detect_initial_group_letter(
        $plainStem
    );

    /*
     * --------------------------------------------------------
     * Step 6: clusters
     * --------------------------------------------------------
     *
     * clusters contains the complete marked stem for debugging.
     *
     * structuralClusters begins with structural_stem so that
     * conjunctions, future markers, and an extracted imperfect
     * marker do not become root candidates.
     */

    $clusters = ace_build_clusters(
        $markedStem
    );

    $structuralClusters = ace_build_clusters(
        $structuralStem
    );

    /*
     * --------------------------------------------------------
     * Step 7: form markers
     * --------------------------------------------------------
     */

    $formMarkers = ace_extract_form_markers(
        $structuralClusters,
        $vn
    );

    $vn = (string) (
        $formMarkers['vn']
        ?? $vn
    );

    $vm = (string) (
        $formMarkers['vm']
        ?? ''
    );

    $fs = (string) (
        $formMarkers['fs']
        ?? ''
    );

    $rootStructuralClusters = is_array(
        $formMarkers['structural_clusters']
        ?? null
    )
        ? $formMarkers['structural_clusters']
        : $structuralClusters;

    /*
     * --------------------------------------------------------
     * Step 8: root extraction
     * --------------------------------------------------------
     */

    $rootAnalysis = ace_extract_root_analysis(
        $rootStructuralClusters,
        $vm,
        $fs
    );

    if (!is_array($rootAnalysis)) {
        $rootAnalysis = [];
    }

    $vm = (string) (
        $rootAnalysis['vm']
        ?? $vm
    );

    $fs = (string) (
        $rootAnalysis['fs']
        ?? $fs
    );

    /*
     * --------------------------------------------------------
     * Step 9: combine grammar candidates
     * --------------------------------------------------------
     *
     * Prefix candidates and suffix candidates are both retained.
     * These are candidate values rather than final classifications.
     */

    $suffixFormCandidates = [];
    $suffixTenseCandidates = [];
    $suffixVoiceCandidates = [];
    $suffixMoodCandidates = [];

    if (!empty($matchedSuffixEntry)) {
        $suffixFamily = (string) (
            $matchedSuffixEntry['family']
            ?? ''
        );

        $suffixForm = (string) (
            $matchedSuffixEntry['form']
            ?? ''
        );

        $suffixTense = (string) (
            $matchedSuffixEntry['tense']
            ?? ''
        );

        $suffixVoice = (string) (
            $matchedSuffixEntry['voice']
            ?? ''
        );

        $suffixMood = (string) (
            $matchedSuffixEntry['mood']
            ?? $suffixType
        );

        if ($suffixFamily !== '') {
            $suffixFormCandidates[] = $suffixFamily;
        }

        if ($suffixForm !== '') {
            $suffixFormCandidates[] = $suffixForm;
        }

        if ($suffixTense !== '') {
            $suffixTenseCandidates[] = $suffixTense;
        }

        if ($suffixVoice !== '') {
            $suffixVoiceCandidates[] = $suffixVoice;
        }

        if ($suffixMood !== '') {
            $suffixMoodCandidates[] = $suffixMood;
        }
    }

    /*
     * Some suffix entries may already provide candidate arrays.
     */

    $suffixCandidateFields = [
        'form_candidates' => &$suffixFormCandidates,
        'tense_candidates' => &$suffixTenseCandidates,
        'voice_candidates' => &$suffixVoiceCandidates,
        'mood_candidates' => &$suffixMoodCandidates,
    ];

    foreach (
        $suffixCandidateFields
        as $fieldName => &$destination
    ) {
        $values = $matchedSuffixEntry[$fieldName]
            ?? [];

        if (is_string($values) && $values !== '') {
            $destination[] = $values;
        } elseif (is_array($values)) {
            foreach ($values as $value) {
                if (
                    is_string($value)
                    && $value !== ''
                ) {
                    $destination[] = $value;
                }
            }
        }
    }

    unset($destination);

    $grammarCandidates = [
        'form_candidates' => array_values(
            array_unique(
                array_merge(
                    $prefixInformation[
                        'prefix_form_candidates'
                    ],
                    $suffixFormCandidates
                )
            )
        ),

        'tense_candidates' => array_values(
            array_unique(
                array_merge(
                    $prefixInformation[
                        'prefix_tense_candidates'
                    ],
                    $suffixTenseCandidates
                )
            )
        ),

        'voice_candidates' => array_values(
            array_unique(
                array_merge(
                    $prefixInformation[
                        'prefix_voice_candidates'
                    ],
                    $suffixVoiceCandidates
                )
            )
        ),

        'mood_candidates' => array_values(
            array_unique(
                array_merge(
                    $prefixInformation[
                        'prefix_mood_candidates'
                    ],
                    $suffixMoodCandidates
                )
            )
        ),
    ];

    /*
     * --------------------------------------------------------
     * Step 10: return complete analysis
     * --------------------------------------------------------
     */

    return [
        /*
         * ====================================================
         * 1. ORIGINAL TOKEN
         * ====================================================
         */

        'token' => $token,

        'token_length' => $tokenLength,

        /*
         * ====================================================
         * 2. SUFFIX ANALYSIS
         * ====================================================
         */

        'matched_suffix' => $matchedSuffix,

        'matched_suffix_entry' => $matchedSuffixEntry,

        'matched_suffix_type' => $suffixType,

        'matched_suffix_length' => $matchedSuffixLength,

        /*
         * ====================================================
         * 3. PREFIX ANALYSIS
         * ====================================================
         */

        'matched_prefix' => $matchedPrefix,

        'matched_prefix_entry' => $matchedPrefixEntry,

        'matched_prefix_length' => $matchedPrefixLength,

        'matched_prefix_entries' => $matchedPrefixEntries,

        'prefix_families' =>
            $prefixInformation['prefix_families'],

        'prefix_form_candidates' =>
            $prefixInformation[
                'prefix_form_candidates'
            ],

        'prefix_tense_candidates' =>
            $prefixInformation[
                'prefix_tense_candidates'
            ],

        'prefix_voice_candidates' =>
            $prefixInformation[
                'prefix_voice_candidates'
            ],

        'prefix_mood_candidates' =>
            $prefixInformation[
                'prefix_mood_candidates'
            ],

        /*
         * ====================================================
         * 4. INITIAL PARTICLES
         * ====================================================
         */

        'conjunction' => $conjunction,

        'future_marker' => $futureMarker,

        /*
         * ====================================================
         * 5. STEM INFORMATION
         * ====================================================
         */

        'marked_stem' => $markedStem,

        'plain_stem' => $plainStem,

        'structural_stem' => $structuralStem,

        'expanded_skeleton' => $expandedSkeleton,

        'token_length_after_suffix_removal' =>
            $tokenLengthAfterSuffixRemoval,

        'plain_stem_length' => $plainStemLength,

        'letters' => is_array($letters)
            ? $letters
            : [],

        'initial_letter' => (string) (
            $initial['letter']
            ?? ''
        ),

        'initial_group_value' => (string) (
            $initial['value']
            ?? ''
        ),

        'is_imperfect_group' => (bool) (
            $initial['is_imperfect_group_letter']
            ?? false
        ),

        /*
         * ====================================================
         * 6. STRUCTURAL MARKERS
         * ====================================================
         */

        'vn' => $vn,

        'vm' => $vm,

        'fs' => $fs,

        /*
         * ====================================================
         * 7. ROOT AND VOWEL POSITIONS
         * ====================================================
         */

        'r0' => (string) (
            $rootAnalysis['r0']
            ?? ''
        ),

        'v1' => (string) (
            $rootAnalysis['v1']
            ?? ''
        ),

        'r1' => (string) (
            $rootAnalysis['r1']
            ?? ''
        ),

        'v2' => (string) (
            $rootAnalysis['v2']
            ?? ''
        ),

        'r2' => (string) (
            $rootAnalysis['r2']
            ?? ''
        ),

        /*
         * ====================================================
         * 8. SHADDA AND REPEATED ROOT LETTERS
         * ====================================================
         */

        'r1_repeat' => (bool) (
            $rootAnalysis['r1_repeat']
            ?? false
        ),

        'r2_repeat' => (bool) (
            $rootAnalysis['r2_repeat']
            ?? false
        ),

        /*
         * ====================================================
         * 9. RAW ROOT MARK ARRAYS
         * ====================================================
         */

        'r0_marks' => is_array(
            $rootAnalysis['r0_marks']
            ?? null
        )
            ? $rootAnalysis['r0_marks']
            : [],

        'r1_marks' => is_array(
            $rootAnalysis['r1_marks']
            ?? null
        )
            ? $rootAnalysis['r1_marks']
            : [],

        'r2_marks' => is_array(
            $rootAnalysis['r2_marks']
            ?? null
        )
            ? $rootAnalysis['r2_marks']
            : [],

        /*
         * ====================================================
         * 10. ROOT ANALYSIS
         * ====================================================
         */

        'root' => (string) (
            $rootAnalysis['root']
            ?? ''
        ),

        'root_candidates' => is_array(
            $rootAnalysis['root_candidates']
            ?? null
        )
            ? $rootAnalysis['root_candidates']
            : [],

        /*
         * ====================================================
         * 11. COMBINED GRAMMAR CANDIDATES
         * ====================================================
         */

        'form_candidates' =>
            $grammarCandidates['form_candidates'],

        'tense_candidates' =>
            $grammarCandidates['tense_candidates'],

        'voice_candidates' =>
            $grammarCandidates['voice_candidates'],

        'mood_candidates' =>
            $grammarCandidates['mood_candidates'],

        /*
         * ====================================================
         * 12. CLUSTER DEBUGGING
         * ====================================================
         */

        'clusters' => is_array($clusters)
            ? $clusters
            : [],

        'structural_clusters' => is_array(
            $rootStructuralClusters
        )
            ? $rootStructuralClusters
            : [],
    ];

}
/*
 * Compatibility helpers retained for older ACE test scripts.
 */
/**
 * Determine the grammatical type represented by a matched suffix.
 *
 * Possible return values:
 * perfect
 * indicative
 * subjunctive
 * jussive
 * imperative
 * declension
 * ''
 */
function ace_detect_suffix_type(array $suffixEntry): string
{
    $families = $suffixEntry['families'] ?? [];
    $moods    = $suffixEntry['moods'] ?? [];

    /*
     * Normalize scalar values to arrays.
     */
    if (!is_array($families)) {
        $families = [$families];
    }

    if (!is_array($moods)) {
        $moods = [$moods];
    }

    /*
     * Normalize all values to lowercase strings.
     */
    $families = array_map(
        static function ($value): string {
            return strtolower(trim((string) $value));
        },
        $families
    );

    $moods = array_map(
        static function ($value): string {
            return strtolower(trim((string) $value));
        },
        $moods
    );

    /*
     * Imperfect suffixes are classified by mood.
     */
    if (in_array('indicative', $moods, true)) {
        return 'indicative';
    }

    if (in_array('subjunctive', $moods, true)) {
        return 'subjunctive';
    }

    if (in_array('jussive', $moods, true)) {
        return 'jussive';
    }

    /*
     * The remaining categories are generally stored as families.
     */
    if (in_array('perfect', $families, true)) {
        return 'perfect';
    }

    if (in_array('imperative', $families, true)) {
        return 'imperative';
    }

    if (in_array('declension', $families, true)) {
        return 'declension';
    }

    /*
     * Allow these values to be stored in moods as well.
     */
    if (in_array('imperative', $moods, true)) {
        return 'imperative';
    }

    if (in_array('declension', $moods, true)) {
        return 'declension';
    }

    return '';
}
function ace_find_suffix(string $token, array $suffixes): array
{
    $result = ace_remove_longest_suffix($token, $suffixes);

    return $result['matched_entry'] ?? ['suffix' => ''];
}

function ace_remove_suffix(string $token, string $suffix): string
{
    if ($suffix === '' || !ace_ends_with($token, $suffix)) {
        return $token;
    }

    $stemLength = mb_strlen($token, 'UTF-8')
        - mb_strlen($suffix, 'UTF-8');

    return mb_substr($token, 0, $stemLength, 'UTF-8');
}

function ace_remove_marks(string $text): string
{
    return ace_remove_diacritics($text);
}
//------------------------
/**
 * Convert any analyzer result value into a displayable string.
 *
 * Arrays are JSON-encoded to prevent array-to-string warnings.
 */
function ace_display_string($value): string
{
    if ($value === null) {
        return '';
    }

    if (is_string($value)) {
        return $value;
    }

    if (
        is_int($value)
        || is_float($value)
        || is_bool($value)
    ) {
        return (string) $value;
    }

    if (is_array($value)) {
        $encoded = json_encode(
            $value,
            JSON_UNESCAPED_UNICODE
            | JSON_UNESCAPED_SLASHES
        );

        return is_string($encoded)
            ? $encoded
            : '';
    }

    if (
        is_object($value)
        && method_exists($value, '__toString')
    ) {
        return (string) $value;
    }

    return '';
}


/**
 * Escape a value for safe HTML display.
 */
function ace_display_html($value): string
{
    return htmlspecialchars(
        ace_display_string($value),
        ENT_QUOTES,
        'UTF-8'
    );
}
/**
 * Separate optional conjunction and future markers from the beginning
 * of a marked Arabic stem.
 *
 * This function is for structural reporting only. It does not alter
 * the original token or the marked stem used by the analyzer.
 */
//function step 1. function ace extract initial markers 
function ace_extract_initial_markers(string $markedStem): array
{
    $remainingStem = $markedStem;

    $conjunction = '';
    $futureMarker = '';
    $vn = '';

    /*
     * Optional conjunction:
     *
     * فَ = fa
     * وَ = wa
     */
    if (mb_substr($remainingStem, 0, 2, 'UTF-8') === 'فَ') {
        $conjunction = 'فَ';
        $remainingStem = mb_substr(
            $remainingStem,
            2,
            null,
            'UTF-8'
        );
    } elseif (
        mb_substr($remainingStem, 0, 2, 'UTF-8') === 'وَ'
    ) {
        $conjunction = 'وَ';
        $remainingStem = mb_substr(
            $remainingStem,
            2,
            null,
            'UTF-8'
        );
    }

    /*
     * Optional future marker:
     *
     * سَ = sa
     *
     * This is checked after removing the optional conjunction.
     */
    if (mb_substr($remainingStem, 0, 2, 'UTF-8') === 'سَ') {
        $futureMarker = 'سَ';
        $remainingStem = mb_substr(
            $remainingStem,
            2,
            null,
            'UTF-8'
        );
    }

    /*
     * Imperfect-person marker.
     *
     * Preserve the marked form so its vowel remains available.
     */
    $possibleVnMarkers = [
        'يَ',
        'يُ',
        'تَ',
        'تُ',
        'أَ',
        'أُ',
        'نَ',
        'نُ',
    ];

    foreach ($possibleVnMarkers as $possibleVn) {
        $possibleLength = mb_strlen(
            $possibleVn,
            'UTF-8'
        );

        if (
            mb_substr(
                $remainingStem,
                0,
                $possibleLength,
                'UTF-8'
            ) !== $possibleVn
        ) {
            continue;
        }

        $vn = $possibleVn;

        $remainingStem = mb_substr(
            $remainingStem,
            $possibleLength,
            null,
            'UTF-8'
        );

        break;
    }

    return [
        'conjunction'    => $conjunction,
        'future_marker'  => $futureMarker,
        'vn'             => $vn,

        /*
         * Stem after conjunction, future marker, and imperfect marker.
         * Use this value to identify vm and fs.
         */
        'structural_stem' => $remainingStem,
    ];
}
function ace_first_vowel_mark(array $marks): string
{
    $vowels = [
        'َ',
        'ُ',
        'ِ',
        'ْ',
        'ً',
        'ٌ',
        'ٍ',
    ];

    foreach ($marks as $mark) {
        if (
            is_string($mark)
            && in_array($mark, $vowels, true)
        ) {
            return $mark;
        }
    }

    return '';
}
function ace_collect_longest_prefix_matches(
    string $token,
    array $masterPrefixes
): array {
    $longestLength = 0;
    $matchedPrefix = '';
    $matches       = [];

    foreach ($masterPrefixes as $entry) {
        if (
            !is_array($entry)
            || !isset($entry['prefix'])
            || !is_string($entry['prefix'])
        ) {
            continue;
        }

        $prefix = $entry['prefix'];

        /*
         * Do not test the zero prefix during normal matching.
         */
        if ($prefix === '') {
            continue;
        }

        /*
         * Prefix must occur at the beginning of the supplied
         * analysis token.
         */
        if (mb_strpos($token, $prefix, 0, 'UTF-8') !== 0) {
            continue;
        }

        $length = ace_strlen($prefix);

        /*
         * A longer prefix replaces all shorter matches.
         */
        if ($length > $longestLength) {
            $longestLength = $length;
            $matchedPrefix = $prefix;
            $matches       = [$entry];

            continue;
        }

        /*
         * Preserve grammatical alternatives that share the
         * same longest prefix.
         */
        if (
            $length === $longestLength
            && $prefix === $matchedPrefix
        ) {
            $matches[] = $entry;
        }
    }

    return [
        'matched_prefix'         => $matchedPrefix,
        'matched_prefix_length'  => $longestLength,
        'matched_prefix_entries' => $matches,
    ];
}
function ace_merge_prefix_match_information(
    array $matchedEntries
): array {
    $forms    = [];
    $families = [];
    $tenses   = [];
    $voices   = [];
    $moods    = [];

    foreach ($matchedEntries as $entry) {
        if (!is_array($entry)) {
            continue;
        }

        $entryForms = $entry['forms'] ?? [];

        if (!is_array($entryForms)) {
            $entryForms = [$entryForms];
        }

        foreach ($entryForms as $form) {
            if ($form === '' || $form === null) {
                continue;
            }

            $forms[] = $form;
        }

        $family = (string) ($entry['family'] ?? '');

        if ($family !== '') {
            $families[] = $family;
        }

        $entryTenses = $entry['tense'] ?? [];

        if (!is_array($entryTenses)) {
            $entryTenses = [$entryTenses];
        }

        foreach ($entryTenses as $tense) {
            $tense = (string) $tense;

            if ($tense !== '') {
                $tenses[] = $tense;
            }
        }

        $entryVoices = $entry['voice'] ?? [];

        if (!is_array($entryVoices)) {
            $entryVoices = [$entryVoices];
        }

        foreach ($entryVoices as $voice) {
            $voice = (string) $voice;

            if ($voice !== '') {
                $voices[] = $voice;
            }
        }

        $entryMoods = $entry['moods'] ?? [];

        if (!is_array($entryMoods)) {
            $entryMoods = [$entryMoods];
        }

        foreach ($entryMoods as $mood) {
            $mood = (string) $mood;

            if ($mood !== '') {
                $moods[] = $mood;
            }
        }
    }

    return [
        'prefix_form_candidates' => array_values(
            array_unique($forms, SORT_REGULAR)
        ),

        'prefix_families' => array_values(
            array_unique($families)
        ),

        'prefix_tense_candidates' => array_values(
            array_unique($tenses)
        ),

        'prefix_voice_candidates' => array_values(
            array_unique($voices)
        ),

        'prefix_mood_candidates' => array_values(
            array_unique($moods)
        ),
    ];
}
function ace_intersect_candidate_values(
    array $first,
    array $second
): array {
    if ($first === []) {
        return array_values(array_unique($second));
    }

    if ($second === []) {
        return array_values(array_unique($first));
    }

    return array_values(
        array_unique(
            array_intersect($first, $second)
        )
    );
}
function ace_resolve_prefix_and_suffix_grammar(
    array $prefixInformation,
    array $suffixMoods
): array {
    $prefixMoods = $prefixInformation[
        'prefix_mood_candidates'
    ] ?? [];

    $resolvedMoods = ace_intersect_candidate_values(
        $prefixMoods,
        $suffixMoods
    );

    /*
     * If the intersection is empty, retain the suffix evidence.
     * The suffix is usually more precise for mood.
     */
    if ($resolvedMoods === [] && $suffixMoods !== []) {
        $resolvedMoods = $suffixMoods;
    }

    return [
        'form_candidates' => $prefixInformation[
            'prefix_form_candidates'
        ] ?? [],

        'tense_candidates' => $prefixInformation[
            'prefix_tense_candidates'
        ] ?? [],

        'voice_candidates' => $prefixInformation[
            'prefix_voice_candidates'
        ] ?? [],

        'mood_candidates' => $resolvedMoods,
    ];
}
/**
 * ------------------------------------------------------------
 * Match the longest prefix from the master prefix table.
 * ------------------------------------------------------------
 */
function ace_match_longest_prefix(
    string $token,
    array $masterPrefixes
): array
{
    $matchedPrefix      = '';
    $matchedPrefixEntry = null;
    $matchedPrefixType  = '';
    $matchedPrefixLength = 0;

    /*
     * Longest prefixes should always be tested first.
     */
    usort(
        $masterPrefixes,
        static function ($a, $b) {
            return ($b['length'] ?? 0)
                <=> ($a['length'] ?? 0);
        }
    );

    foreach ($masterPrefixes as $entry) {

        if (
            !is_array($entry)
            || !isset($entry['prefix'])
            || !is_string($entry['prefix'])
        ) {
            continue;
        }

        $prefix = $entry['prefix'];

        if ($prefix === '') {
            continue;
        }

        if (mb_substr($token, 0, mb_strlen($prefix)) === $prefix) {

            $matchedPrefix       = $prefix;
            $matchedPrefixEntry  = $entry;
            $matchedPrefixLength = mb_strlen($prefix);

            /*
             * Optional classification.
             */
            if (isset($entry['family'])) {
                $matchedPrefixType = $entry['family'];
            }

            break;
        }
    }

    return [
        'matched_prefix'        => $matchedPrefix,
        'matched_prefix_entry'  => $matchedPrefixEntry,
        'matched_prefix_type'   => $matchedPrefixType,
        'matched_prefix_length' => $matchedPrefixLength,
    ];
}
/**
 * Run the cumulative ACE reverse-engine pipeline for one token.
 *
 * The pipeline begins with the stable simple analyzer and then passes the
 * cumulative analysis array through each available new-system stage.
 *
 * Missing optional stages are skipped so individual test pages can still run
 * while the pipeline is being developed.
 *
 * @param string $token
 * @param array  $masterSuffixes
 *
 * @return array
 */

/**
 * Run the cumulative ACE reverse-engine analysis pipeline.
 *
 * Every stage receives the current analysis array whenever possible.
 * Array results are merged cumulatively so earlier fields are preserved.
 */