<?php
declare(strict_types=1);

/* Contrat des rencontres a phases, cote PHP.
 *
 * Le vocabulaire — horloges, activations, champs surchargeables, adresses
 * locales, bornes — n'est PAS ecrit ici: il vit dans data/encounter_contract.json,
 * que Godot lit aussi. Deux copies d'une meme liste finissent toujours par
 * diverger; le projet en a deja fait les frais avec les etats d'animation, et
 * c'est pour cela que le systeme sonore a adopte ce schema. Ici, un seul fichier.
 *
 * Ce module ne fait tourner aucun combat: il valide et normalise des donnees.
 */

function encounter_contract(): array
{
    static $contract = null;
    if ($contract !== null) {
        return $contract;
    }
    $path = dirname(__DIR__, 2) . '/data/encounter_contract.json';
    $raw = is_file($path) ? file_get_contents($path) : false;
    if ($raw === false) {
        throw new RuntimeException("Contrat des rencontres introuvable: $path");
    }
    $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($decoded) || !isset($decoded['overridable']) || !isset($decoded['defaults'])) {
        throw new RuntimeException('Contrat des rencontres illisible.');
    }
    $contract = $decoded;
    return $contract;
}

function encounter_default(string $key)
{
    return encounter_contract()['defaults'][$key] ?? null;
}

function encounter_bounds(string $key): array
{
    $bounds = encounter_contract()['bounds'][$key] ?? ['min' => 0, 'max' => 0];
    return [(float)($bounds['min'] ?? 0), (float)($bounds['max'] ?? 0)];
}

/* Un nombre du contrat, ramene dans ses bornes avec un message qui nomme le
 * champ. Les erreurs remontent par reference comme partout ailleurs ici. */
function encounter_number($value, string $boundsKey, float $fallback, string $prefix, array &$errors): float
{
    [$min, $max] = encounter_bounds($boundsKey);
    if (!is_numeric($value) || (float)$value < $min || (float)$value > $max) {
        $errors[] = "$prefix: $boundsKey doit etre entre $min et $max.";
        return $fallback;
    }
    return (float)$value;
}

function encounter_enum($value, string $listKey, string $fallback, string $prefix, array &$errors): string
{
    $allowed = encounter_contract()[$listKey] ?? [];
    $text = (string)$value;
    if (!in_array($text, $allowed, true)) {
        $errors[] = "$prefix: $listKey inconnu '$text'.";
        return $fallback;
    }
    return $text;
}

/* La protection d'une phase. Bouclier, armure, faiblesse et resistance restent
 * DISTINCTS: les confondre rendrait impossible d'ecrire une carapace qu'on ne
 * peut ni blesser ni interrompre, suivie d'une recharge vulnerable. */
function normalize_encounter_defense($value, string $prefix, array &$errors): array
{
    $spec = encounter_contract()['defense'];
    if ($value === null || $value === [] || $value instanceof stdClass) {
        return [];
    }
    if (!is_array($value)) {
        $errors[] = "$prefix / defense: objet attendu.";
        return [];
    }
    $result = [];
    foreach ($spec as $field => $rule) {
        if (!array_key_exists($field, $value)) {
            continue; // absent = herite, et c'est une information
        }
        if (($rule['kind'] ?? '') === 'bool') {
            if (!is_bool($value[$field])) {
                $errors[] = "$prefix / defense: $field doit etre booleen.";
                continue;
            }
            $result[$field] = $value[$field];
            continue;
        }
        $number = $value[$field];
        $min = (float)($rule['min'] ?? 0);
        $max = (float)($rule['max'] ?? 0);
        if (!is_numeric($number) || (float)$number < $min || (float)$number > $max) {
            $errors[] = "$prefix / defense: $field doit etre entre $min et $max.";
            continue;
        }
        $result[$field] = (float)$number;
    }
    return $result;
}

/* Les surcharges d'une phase. On garde UNIQUEMENT ce que l'auteur a ecrit:
 * recopier la base dans chaque phase transformerait l'heritage en copies qui
 * divergent, et l'auteur ne saurait plus ce qu'il a reellement decide. */
function normalize_encounter_overrides($value, array $allowedFields, array $catalogues, string $prefix, array &$errors): array
{
    if ($value === null || $value === [] || $value instanceof stdClass) {
        return [];
    }
    if (!is_array($value)) {
        $errors[] = "$prefix / surcharges: objet attendu.";
        return [];
    }
    $contract = encounter_contract();
    $result = [];
    foreach ($value as $field => $entry) {
        $field = (string)$field;
        if (in_array($field, $contract['forbidden'], true)) {
            $errors[] = "$prefix: '$field' ne peut pas etre surcharge par une phase.";
            continue;
        }
        if (!in_array($field, $contract['overridable'], true)) {
            $errors[] = "$prefix: '$field' n'est pas surchargeable.";
            continue;
        }
        if ($field === 'defense') {
            $defense = normalize_encounter_defense($entry, $prefix, $errors);
            if ($defense !== []) {
                $result['defense'] = $defense;
            }
            continue;
        }
        /* Une surcharge qui RENVOIE ailleurs doit etre verifiee ici. Elle ne
           repasse par aucun autre normaliseur: une phase pouvait viser un
           pattern efface et la publication l'acceptait sans un mot, la creature
           se retrouvant muette dans cette phase la. */
        if ($field === 'attacks') {
            $result['attacks'] = normalize_enemy_attack_list(
                $entry, $catalogues['attack_patterns'] ?? [],
                (float)($catalogues['knockback'] ?? 240.0), $prefix, $errors);
            continue;
        }
        if ($field === 'aggro_movement') {
            $movement = is_string($entry) ? $entry : '';
            if (!array_key_exists($movement, $catalogues['movement_patterns'] ?? [])) {
                $errors[] = "$prefix: deplacement de combat inconnu '$movement'.";
                continue;
            }
            $result['aggro_movement'] = $movement;
            continue;
        }
        // Les autres champs sont des valeurs simples, bornees par le contrat.
        $result[$field] = $entry;
    }
    return $result;
}

/* Un segment: la presence d'une phase dans un intervalle [start, end) d'une
 * ligne. A une frontiere exacte, c'est le segment SUIVANT qui gagne, jamais les
 * deux — d'ou l'intervalle semi-ouvert. */
function normalize_encounter_segment($value, array $phaseIds, string $prefix, array &$errors): array
{
    if (!is_array($value)) {
        $errors[] = "$prefix: segment invalide.";
        return [];
    }
    $id = trim((string)($value['id'] ?? ''));
    if (!valid_id($id)) {
        $errors[] = "$prefix: identifiant de segment invalide.";
        return [];
    }
    $phase = trim((string)($value['phase'] ?? ''));
    if (!in_array($phase, $phaseIds, true)) {
        $errors[] = "$prefix: phase inconnue '$phase'.";
        return [];
    }
    $start = encounter_number($value['start'] ?? 0.0, 'segment_start', 0.0, $prefix, $errors);
    $end = encounter_number($value['end'] ?? 0.0, 'segment_end', 0.0, $prefix, $errors);
    $floor = (float)(encounter_contract()['bounds']['segment_min_seconds'] ?? 0.05);
    if ($end - $start < $floor) {
        // Un segment nul n'est pas une phase: elle doit durer au moins un tick.
        $errors[] = "$prefix: un segment doit durer au moins $floor s.";
        return [];
    }
    return ['id' => $id, 'phase' => $phase, 'start' => $start, 'end' => $end];
}

/* La boucle: une seule plage par ligne, dont les bornes coincident avec des
 * frontieres de segments. Elle remplace cinquante copies d'une alternance. */
function normalize_encounter_loop($value, array $segments, string $prefix, array &$errors): array
{
    if ($value === null || $value === [] || $value instanceof stdClass) {
        return [];
    }
    if (!is_array($value) || $segments === []) {
        $errors[] = "$prefix / boucle: objet attendu sur une ligne qui a des segments.";
        return [];
    }
    $start = encounter_number($value['start'] ?? 0.0, 'segment_start', 0.0, $prefix, $errors);
    $end = encounter_number($value['end'] ?? 0.0, 'segment_end', 0.0, $prefix, $errors);
    if ($end <= $start) {
        $errors[] = "$prefix / boucle: la fin doit suivre le debut.";
        return [];
    }
    // Couper un segment en deux rendrait la repetition illisible et le calcul du
    // curseur ambigu: les bornes doivent tomber sur des frontieres.
    $edges = [];
    foreach ($segments as $segment) {
        $edges[] = $segment['start'];
        $edges[] = $segment['end'];
    }
    if (!in_array($start, $edges, true) || !in_array($end, $edges, true)) {
        $errors[] = "$prefix / boucle: ses bornes doivent coincider avec des frontieres de segments.";
        return [];
    }
    $iterations = $value['iterations'] ?? null;
    if ($iterations !== null) {
        [$min, $max] = encounter_bounds('loop_iterations');
        if (!is_numeric($iterations) || (int)$iterations != $iterations || (int)$iterations < $min || (int)$iterations > $max) {
            $errors[] = "$prefix / boucle: le nombre de tours doit etre un entier entre $min et $max, ou vide pour l'infini.";
            $iterations = null;
        } else {
            $iterations = (int)$iterations;
        }
    }
    // Un segment place apres une boucle infinie ne sera JAMAIS atteint. Le
    // laisser visible dans l'editeur serait une fausse suite.
    if ($iterations === null) {
        foreach ($segments as $segment) {
            if ($segment['start'] >= $end) {
                $errors[] = "$prefix: un segment suit une boucle infinie et ne pourra jamais etre atteint.";
                break;
            }
        }
    }
    return ['start' => $start, 'end' => $end, 'iterations' => $iterations];
}

/* Une ligne: sa condition d'activation, son horloge, ses segments et sa
 * politique de fin. L'ORDRE du tableau est l'unique priorite — index 0 au
 * dessus. Pas de second entier qui pourrait contredire l'ordre visuel. */
function normalize_encounter_track($value, array $phaseIds, array $variables, array $facts, string $prefix, array &$errors): array
{
    if (!is_array($value)) {
        $errors[] = "$prefix: ligne invalide.";
        return [];
    }
    $id = trim((string)($value['id'] ?? ''));
    if (!valid_id($id)) {
        $errors[] = "$prefix: identifiant de ligne invalide.";
        return [];
    }
    $enabled = $value['enabled'] ?? true;
    if (!is_bool($enabled)) {
        $errors[] = "$prefix: enabled doit etre booleen.";
        $enabled = true;
    }
    $track = [
        'id' => $id,
        'label' => trim((string)($value['label'] ?? $id)),
        'enabled' => $enabled,
        'activation' => encounter_enum($value['activation'] ?? encounter_default('activation'),
            'activations', (string)encounter_default('activation'), $prefix, $errors),
        'clock' => encounter_enum($value['clock'] ?? encounter_default('clock'),
            'clocks', (string)encounter_default('clock'), $prefix, $errors),
        'on_preempted' => encounter_enum($value['on_preempted'] ?? encounter_default('on_preempted'),
            'preemptions', (string)encounter_default('on_preempted'), $prefix, $errors),
        'after_end' => encounter_enum($value['after_end'] ?? encounter_default('after_end'),
            'after_end', (string)encounter_default('after_end'), $prefix, $errors),
    ];
    $track['activation_delay_seconds'] = encounter_number(
        $value['activation_delay_seconds'] ?? encounter_default('activation_delay_seconds'),
        'activation_delay_seconds', (float)encounter_default('activation_delay_seconds'), $prefix, $errors);
    $track['rearm_delay_seconds'] = encounter_number(
        $value['rearm_delay_seconds'] ?? encounter_default('rearm_delay_seconds'),
        'rearm_delay_seconds', (float)encounter_default('rearm_delay_seconds'), $prefix, $errors);

    // La transition: comment cette ligne prend la main sur un geste engage.
    $rawTransition = $value['transition'] ?? [];
    $rawTransition = is_array($rawTransition) ? $rawTransition : [];
    $urgent = $rawTransition['urgent'] ?? false;
    if (!is_bool($urgent)) {
        $errors[] = "$prefix / transition: urgent doit etre booleen.";
        $urgent = false;
    }
    $track['transition'] = [
        'mode' => encounter_enum($rawTransition['mode'] ?? encounter_default('transition_mode'),
            'transition_modes', (string)encounter_default('transition_mode'), $prefix, $errors),
        'max_wait_seconds' => encounter_number($rawTransition['max_wait_seconds'] ?? encounter_default('max_wait_seconds'),
            'max_wait_seconds', (float)encounter_default('max_wait_seconds'), $prefix, $errors),
        'urgent' => $urgent,
    ];

    $track['conditions'] = normalize_upgrade_conditions($value['conditions'] ?? [], $variables, $facts, [], $prefix, $errors);

    $segments = [];
    foreach (($value['segments'] ?? []) as $index => $rawSegment) {
        $segment = normalize_encounter_segment($rawSegment, $phaseIds, "$prefix / segment " . ($index + 1), $errors);
        if ($segment !== []) {
            $segments[] = $segment;
        }
    }
    usort($segments, static fn(array $a, array $b): int => $a['start'] <=> $b['start']);
    // Sur UNE MEME ligne les chevauchements sont refuses: une ligne propose au
    // plus un segment a un instant donne. Entre lignes ils sont normaux, et
    // c'est la priorite qui tranche.
    for ($index = 1; $index < count($segments); $index++) {
        if ($segments[$index]['start'] < $segments[$index - 1]['end']) {
            $errors[] = "$prefix: deux segments se chevauchent sur la meme ligne.";
            break;
        }
    }
    if ($segments === []) {
        $errors[] = "$prefix: une ligne sans segment ne propose jamais rien.";
    }
    $track['segments'] = $segments;
    $track['loop'] = json_map(normalize_encounter_loop($value['loop'] ?? null, $segments, $prefix, $errors));
    return $track;
}

/* La rencontre d'une fiche de niveau. Absente ou desactivee, la creature se
 * comporte EXACTEMENT comme avant ce chantier: c'est la condition pour que les
 * quarante-huit monstres publies ne bougent pas d'un pixel. */
function normalize_encounter($value, array $variables, array $facts, array $catalogues, string $prefix, array &$errors): array
{
    if ($value === null || $value === [] || $value instanceof stdClass) {
        return [];
    }
    if (!is_array($value)) {
        $errors[] = "$prefix / rencontre: objet attendu.";
        return [];
    }
    $enabled = $value['enabled'] ?? encounter_default('enabled');
    if (!is_bool($enabled)) {
        $errors[] = "$prefix / rencontre: enabled doit etre booleen.";
        $enabled = (bool)encounter_default('enabled');
    }
    $result = [
        'enabled' => $enabled,
        'kind' => encounter_enum($value['kind'] ?? encounter_default('kind'), 'kinds',
            (string)encounter_default('kind'), $prefix, $errors),
        'start' => encounter_enum($value['start'] ?? encounter_default('start'), 'starts',
            (string)encounter_default('start'), $prefix, $errors),
        'engagement' => encounter_enum($value['engagement'] ?? encounter_default('engagement'), 'engagements',
            (string)encounter_default('engagement'), $prefix, $errors),
    ];

    $phases = [];
    $phaseIds = [];
    foreach (($value['phases'] ?? []) as $index => $rawPhase) {
        $phasePrefix = "$prefix / phase " . ($index + 1);
        if (!is_array($rawPhase)) {
            $errors[] = "$phasePrefix: entree invalide.";
            continue;
        }
        $id = trim((string)($rawPhase['id'] ?? ''));
        if (!valid_id($id) || in_array($id, $phaseIds, true)) {
            $errors[] = "$phasePrefix: identifiant de phase invalide ou repete.";
            continue;
        }
        $phaseIds[] = $id;
        $phases[] = [
            'id' => $id,
            'display_name' => trim((string)($rawPhase['display_name'] ?? $id)),
            'min_active_seconds' => encounter_number($rawPhase['min_active_seconds'] ?? encounter_default('min_active_seconds'),
                'min_active_seconds', (float)encounter_default('min_active_seconds'), $phasePrefix, $errors),
            'overrides' => json_map(normalize_encounter_overrides(
                $rawPhase['overrides'] ?? [], [], $catalogues, $phasePrefix, $errors)),
        ];
    }
    $result['phases'] = $phases;

    $defaultPhase = trim((string)($value['default_phase'] ?? ''));
    if ($phases !== [] && !in_array($defaultPhase, $phaseIds, true)) {
        $errors[] = "$prefix / rencontre: la phase par defaut '$defaultPhase' est inconnue.";
        $defaultPhase = $phaseIds[0] ?? '';
    }
    $result['default_phase'] = $defaultPhase;

    $tracks = [];
    $trackIds = [];
    foreach (($value['tracks'] ?? []) as $index => $rawTrack) {
        $track = normalize_encounter_track($rawTrack, $phaseIds, $variables, $facts, "$prefix / ligne " . ($index + 1), $errors);
        if ($track === []) {
            continue;
        }
        if (in_array($track['id'], $trackIds, true)) {
            $errors[] = "$prefix: identifiant de ligne repete '{$track['id']}'.";
            continue;
        }
        $trackIds[] = $track['id'];
        $tracks[] = $track;
    }
    $result['tracks'] = $tracks;

    // Une rencontre active sans phase ne decrit rien: mieux vaut le dire a la
    // publication que de laisser un boss muet en jeu.
    if ($enabled && $phases === []) {
        $errors[] = "$prefix / rencontre: activee, mais sans aucune phase.";
    }
    return $result;
}
